From 25dee16ebf1ac0c6e91463daefdf4e59dc273ddb Mon Sep 17 00:00:00 2001 From: maurei Date: Sat, 3 Oct 2020 21:47:32 +0200 Subject: [PATCH 001/240] feat: ICreateRelationshipService and controller method --- .../Controllers/BaseJsonApiController.cs | 21 +++++++++++++++++-- .../Controllers/JsonApiController.cs | 16 ++++++++++---- .../Services/ICreateRelationshipService.cs | 19 +++++++++++++++++ .../Services/IResourceCommandService.cs | 1 + 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index d33ad1aecc..58bbd51d1a 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -25,6 +25,7 @@ public abstract class BaseJsonApiController : CoreJsonApiControl private readonly IGetRelationshipService _getRelationship; private readonly ICreateService _create; private readonly IUpdateService _update; + private readonly ICreateRelationshipService _createRelationship; private readonly IUpdateRelationshipService _updateRelationships; private readonly IDeleteService _delete; private readonly TraceLogWriter> _traceWriter; @@ -65,6 +66,7 @@ protected BaseJsonApiController( ICreateService create = null, IUpdateService update = null, IUpdateRelationshipService updateRelationships = null, + ICreateRelationshipService createRelationship = null, IDeleteService delete = null) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -78,6 +80,7 @@ protected BaseJsonApiController( _create = create; _update = update; _updateRelationships = updateRelationships; + _createRelationship = createRelationship; _delete = delete; } @@ -199,6 +202,19 @@ public virtual async Task PatchRelationshipAsync(TId id, string r return Ok(); } + /// + /// Adds a resource to a to-many relationship. + /// + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + { + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + if (_createRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); + await _createRelationship.CreateRelationshipAsync(id, relationshipName, relationships); + return Ok(); + } + /// /// Deletes a resource. /// @@ -243,10 +259,11 @@ protected BaseJsonApiController( IGetRelationshipService getRelationship = null, ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, + IUpdateRelationshipService updateRelationship = null, + ICreateRelationshipService createRelationship = null, IDeleteService delete = null) : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationships, delete) + updateRelationship, createRelationship, delete) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 1fd42b97aa..b1cbf2aed0 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -34,10 +34,11 @@ public JsonApiController( IGetRelationshipService getRelationship = null, ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, + IUpdateRelationshipService updateRelationship = null, + ICreateRelationshipService createRelationship = null, IDeleteService delete = null) : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationships, delete) + updateRelationship, createRelationship, delete) { } /// @@ -75,6 +76,12 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc public override async Task PatchRelationshipAsync( TId id, string relationshipName, [FromBody] object relationships) => await base.PatchRelationshipAsync(id, relationshipName, relationships); + + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + TId id, string relationshipName, [FromBody] object relationships) + => await base.PostRelationshipAsync(id, relationshipName, relationships); /// [HttpDelete("{id}")] @@ -102,10 +109,11 @@ public JsonApiController( IGetRelationshipService getRelationship = null, ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, + IUpdateRelationshipService updateRelationship = null, + ICreateRelationshipService createRelationship = null, IDeleteService delete = null) : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationships, delete) + updateRelationship, createRelationship, delete) { } } } diff --git a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs b/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs new file mode 100644 index 0000000000..3b1ddc1c2c --- /dev/null +++ b/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface ICreateRelationshipService : ICreateRelationshipService + where TResource : class, IIdentifiable { } + + /// + public interface ICreateRelationshipService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to update an existing relationship. + /// + Task CreateRelationshipAsync(TId id, string relationshipName, object relationships); + } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index c756d3a87b..d388eac930 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -24,6 +24,7 @@ public interface IResourceCommandService : ICreateService, IUpdateService, IUpdateRelationshipService, + ICreateRelationshipService, IDeleteService where TResource : class, IIdentifiable { } From 2660381e8b8ba6e3f85adbdb7a53a0640ad3e181 Mon Sep 17 00:00:00 2001 From: maurei Date: Sat, 3 Oct 2020 21:50:58 +0200 Subject: [PATCH 002/240] feat: ICreateRelationshipService and controller method --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 3 ++- .../Controllers/JsonApiCommandController.cs | 7 +++++++ src/JsonApiDotNetCore/Controllers/JsonApiController.cs | 3 ++- .../Services/ICreateRelationshipService.cs | 4 +++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 58bbd51d1a..5290e2c0a4 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -205,7 +206,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r /// /// Adds a resource to a to-many relationship. /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) { _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 6e4b85dc4d..2bc4e4a502 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -40,6 +41,12 @@ public override async Task PatchRelationshipAsync( TId id, string relationshipName, [FromBody] object relationships) => await base.PatchRelationshipAsync(id, relationshipName, relationships); + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + TId id, string relationshipName, [FromBody] IEnumerable relationships) + => await base.PostRelationshipAsync(id, relationshipName, relationships); + /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index b1cbf2aed0..1361e8c771 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -80,7 +81,7 @@ public override async Task PatchRelationshipAsync( /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationships) + TId id, string relationshipName, [FromBody] IEnumerable relationships) => await base.PostRelationshipAsync(id, relationshipName, relationships); /// diff --git a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs b/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs index 3b1ddc1c2c..9dd5a25f10 100644 --- a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Resources; @@ -14,6 +16,6 @@ public interface ICreateRelationshipService /// /// Handles a json:api request to update an existing relationship. /// - Task CreateRelationshipAsync(TId id, string relationshipName, object relationships); + Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); } } From 9c046207a70c386e424e56c3e833c66099d5d7af Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 11:24:40 +0200 Subject: [PATCH 003/240] feat: introduction of ICreateRelationshipService and IDeleteRelationshipService in controller and service layer --- .../JsonApiApplicationBuilder.cs | 17 +- .../Configuration/ServiceDiscoveryFacade.cs | 12 +- .../Controllers/BaseJsonApiController.cs | 179 ++++++++++-------- .../Controllers/JsonApiController.cs | 76 +++++--- .../Services/ICreateRelationshipService.cs | 5 +- .../Services/IDeleteRelationshipService.cs | 19 ++ .../Services/IResourceCommandService.cs | 7 +- .../Services/JsonApiResourceService.cs | 10 + .../BaseJsonApiController_Tests.cs | 6 +- 9 files changed, 210 insertions(+), 121 deletions(-) create mode 100644 src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 7aeffd5518..6aefff4ec3 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -201,10 +201,7 @@ private void AddServiceLayer() _services.AddScoped(typeof(IGetByIdService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IGetByIdService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(JsonApiResourceService<,>)); - + _services.AddScoped(typeof(IGetSecondaryService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IGetSecondaryService<,>), typeof(JsonApiResourceService<,>)); @@ -214,6 +211,18 @@ private void AddServiceLayer() _services.AddScoped(typeof(IDeleteService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IDeleteService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(typeof(ICreateRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(ICreateRelationshipService<,>), typeof(JsonApiResourceService<,>)); + + _services.AddScoped(typeof(IGetRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(JsonApiResourceService<,>)); + + _services.AddScoped(typeof(IUpdateRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IUpdateRelationshipService<,>), typeof(JsonApiResourceService<,>)); + + _services.AddScoped(typeof(IDeleteRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IDeleteRelationshipService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(typeof(IResourceService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IResourceService<,>), typeof(JsonApiResourceService<,>)); diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index b28fe466ea..3fcf712930 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -32,12 +32,18 @@ public class ServiceDiscoveryFacade typeof(IGetByIdService<,>), typeof(IGetSecondaryService<>), typeof(IGetSecondaryService<,>), - typeof(IGetRelationshipService<>), - typeof(IGetRelationshipService<,>), typeof(IUpdateService<>), typeof(IUpdateService<,>), typeof(IDeleteService<>), - typeof(IDeleteService<,>) + typeof(IDeleteService<,>), + typeof(ICreateRelationshipService<>), + typeof(ICreateRelationshipService<,>), + typeof(IGetRelationshipService<>), + typeof(IGetRelationshipService<,>), + typeof(IUpdateRelationshipService<>), + typeof(IUpdateRelationshipService<,>), + typeof(IDeleteRelationshipService<>), + typeof(IDeleteRelationshipService<,>) }; private static readonly HashSet _repositoryInterfaces = new HashSet { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 5290e2c0a4..83b2d5229d 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -26,9 +27,10 @@ public abstract class BaseJsonApiController : CoreJsonApiControl private readonly IGetRelationshipService _getRelationship; private readonly ICreateService _create; private readonly IUpdateService _update; - private readonly ICreateRelationshipService _createRelationship; - private readonly IUpdateRelationshipService _updateRelationships; private readonly IDeleteService _delete; + private readonly ICreateRelationshipService _createRelationship; + private readonly IUpdateRelationshipService _updateRelationship; + private readonly IDeleteRelationshipService _deleteRelationship; private readonly TraceLogWriter> _traceWriter; /// @@ -50,8 +52,8 @@ protected BaseJsonApiController( ILoggerFactory loggerFactory, IResourceQueryService queryService = null, IResourceCommandService commandService = null) - : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, - commandService, commandService, commandService) + : this(options, loggerFactory, commandService, queryService, queryService, queryService, commandService, + commandService, commandService, queryService) { } /// @@ -60,31 +62,63 @@ protected BaseJsonApiController( protected BaseJsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, + ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, - ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, + IDeleteService delete = null, ICreateRelationshipService createRelationship = null, - IDeleteService delete = null) + IGetRelationshipService getRelationship = null, + IUpdateRelationshipService updateRelationship = null, + IDeleteRelationshipService deleteRelationship = null + ) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _options = options ?? throw new ArgumentNullException(nameof(options)); _traceWriter = new TraceLogWriter>(loggerFactory); + _create = create; _getAll = getAll; _getById = getById; _getSecondary = getSecondary; - _getRelationship = getRelationship; - _create = create; _update = update; - _updateRelationships = updateRelationships; - _createRelationship = createRelationship; _delete = delete; + _createRelationship = createRelationship; + _getRelationship = getRelationship; + _updateRelationship = updateRelationship; + _deleteRelationship = deleteRelationship; } + #region Primary Resource Endpoints + + /// + /// Creates a new resource. + /// + public virtual async Task PostAsync([FromBody] TResource resource) + { + _traceWriter.LogMethodStart(new {resource}); + + if (_create == null) + throw new RequestMethodNotAllowedException(HttpMethod.Post); + + if (resource == null) + throw new InvalidRequestBodyException(null, null, null); + + if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) + throw new ResourceIdInPostRequestNotAllowedException(); + + if (_options.ValidateModelState && !ModelState.IsValid) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); + } + + resource = await _create.CreateAsync(resource); + + return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); + } + /// /// Gets a collection of top-level (non-nested) resources. /// Example: GET /articles HTTP/1.1 @@ -110,22 +144,7 @@ public virtual async Task GetAsync(TId id) var resource = await _getById.GetAsync(id); return Ok(resource); } - - /// - /// Gets a single resource relationship. - /// Example: GET /articles/1/relationships/author HTTP/1.1 - /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) - { - _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - - if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); - - return Ok(relationship); - } - + /// /// Gets a single resource or multiple resources at a nested endpoint. /// Examples: @@ -141,34 +160,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } - - /// - /// Creates a new resource. - /// - public virtual async Task PostAsync([FromBody] TResource resource) - { - _traceWriter.LogMethodStart(new {resource}); - - if (_create == null) - throw new RequestMethodNotAllowedException(HttpMethod.Post); - - if (resource == null) - throw new InvalidRequestBodyException(null, null, null); - - if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) - throw new ResourceIdInPostRequestNotAllowedException(); - - if (_options.ValidateModelState && !ModelState.IsValid) - { - var namingStrategy = _options.SerializerContractResolver.NamingStrategy; - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); - } - - resource = await _create.CreateAsync(resource); - - return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); - } - + /// /// Updates an existing resource. May contain a partial set of attributes. /// @@ -189,22 +181,40 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource var updated = await _update.UpdateAsync(id, resource); return updated == null ? Ok(null) : Ok(updated); } + + /// + /// Deletes a resource. + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); + await _delete.DeleteAsync(id); + + return NoContent(); + } + #endregion + + #region Relationship Link Endpoints + /// - /// Updates a relationship. + /// Gets a single resource relationship. + /// Example: GET /articles/1/relationships/author HTTP/1.1 /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_updateRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _updateRelationships.UpdateRelationshipAsync(id, relationshipName, relationships); - return Ok(); - } + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + return Ok(relationship); + } + /// - /// Adds a resource to a to-many relationship. + /// Adds resources to a to-many relationship. /// public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) { @@ -217,17 +227,32 @@ public virtual async Task PostRelationshipAsync(TId id, string re } /// - /// Deletes a resource. + /// Updates a relationship. /// - public virtual async Task DeleteAsync(TId id) + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) { - _traceWriter.LogMethodStart(new {id}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _delete.DeleteAsync(id); + if (_updateRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); + await _updateRelationship.UpdateRelationshipAsync(id, relationshipName, relationships); + return Ok(); + } + + /// + /// Removes resources from a to-many relationship. + /// + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) + { + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - return NoContent(); + if (_deleteRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); + await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, relationships); + return Ok(); } + + #endregion } /// @@ -254,17 +279,19 @@ protected BaseJsonApiController( protected BaseJsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, + ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, - ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationship = null, + IDeleteService delete = null, ICreateRelationshipService createRelationship = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationship, createRelationship, delete) + IGetRelationshipService getRelationship = null, + IUpdateRelationshipService updateRelationship = null, + IDeleteRelationshipService deleteRelationship = null + ) + : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, + createRelationship, getRelationship, updateRelationship, deleteRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 1361e8c771..3ef9219491 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -29,19 +29,27 @@ public JsonApiController( public JsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, + ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, - ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationship = null, + IDeleteService delete = null, ICreateRelationshipService createRelationship = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationship, createRelationship, delete) + IGetRelationshipService getRelationship = null, + IUpdateRelationshipService updateRelationship = null, + IDeleteRelationshipService deleteRelationship = null) + : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, createRelationship, + getRelationship, updateRelationship, deleteRelationship) { } + #region Primary Resource Endpoints + + /// + [HttpPost] + public override async Task PostAsync([FromBody] TResource resource) + => await base.PostAsync(resource); + /// [HttpGet] public override async Task GetAsync() => await base.GetAsync(); @@ -49,21 +57,12 @@ public JsonApiController( /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); - - /// - [HttpPost] - public override async Task PostAsync([FromBody] TResource resource) - => await base.PostAsync(resource); + /// [HttpPatch("{id}")] @@ -73,20 +72,36 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc } /// - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + [HttpDelete("{id}")] + public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); + + #endregion + #region Relationship Link Endpoints + /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( TId id, string relationshipName, [FromBody] IEnumerable relationships) => await base.PostRelationshipAsync(id, relationshipName, relationships); - + /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); + + /// + [HttpPatch("{id}/relationships/{relationshipName}")] + public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + => await base.PatchRelationshipAsync(id, relationshipName, relationships); + + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) + => await base.DeleteRelationshipAsync(id, relationshipName, relationships); + + #endregion + } /// @@ -104,17 +119,18 @@ public JsonApiController( public JsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, + ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, - ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationship = null, + IDeleteService delete = null, ICreateRelationshipService createRelationship = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationship, createRelationship, delete) + IGetRelationshipService getRelationship = null, + IUpdateRelationshipService updateRelationship = null, + IDeleteRelationshipService deleteRelationship = null) + : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, createRelationship, + getRelationship, updateRelationship, deleteRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs b/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs index 9dd5a25f10..54f6101afd 100644 --- a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs @@ -10,11 +10,10 @@ public interface ICreateRelationshipService : ICreateRelationshipServ where TResource : class, IIdentifiable { } /// - public interface ICreateRelationshipService - where TResource : class, IIdentifiable + public interface ICreateRelationshipService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to update an existing relationship. + /// Handles a json:api request to add resources to a to-many relationship. /// Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); } diff --git a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs new file mode 100644 index 0000000000..e5c1efad83 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IDeleteRelationshipService : IDeleteRelationshipService + where TResource : class, IIdentifiable { } + + /// + public interface IDeleteRelationshipService where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to remove resources from a to-many relationship. + /// + Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); + } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index d388eac930..0fc6a64042 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -9,8 +9,10 @@ namespace JsonApiDotNetCore.Services public interface IResourceCommandService : ICreateService, IUpdateService, - IUpdateRelationshipService, IDeleteService, + IUpdateRelationshipService, + ICreateRelationshipService, + IDeleteRelationshipService, IResourceCommandService where TResource : class, IIdentifiable { } @@ -23,9 +25,10 @@ public interface IResourceCommandService : public interface IResourceCommandService : ICreateService, IUpdateService, + IDeleteService, IUpdateRelationshipService, ICreateRelationshipService, - IDeleteService + IDeleteRelationshipService where TResource : class, IIdentifiable { } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 15dc320c78..6036014248 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -347,6 +347,16 @@ public virtual async Task UpdateRelationshipAsync(TId id, string relationshipNam _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); } } + + public Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + { + throw new NotImplementedException(); + } + + public Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + { + throw new NotImplementedException(); + } private void AssertPrimaryResourceExists(TResource resource) { diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 10d71866b9..7704b08f9d 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -41,10 +41,10 @@ public ResourceController( IGetRelationshipService getRelationship = null, ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, + IUpdateRelationshipService updateRelationship = null, IDeleteService delete = null) : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, - update, updateRelationships, delete) + update, updateRelationship, delete) { } } @@ -222,7 +222,7 @@ public async Task PatchRelationshipsAsync_Calls_Service() // Arrange const int id = 0; var serviceMock = new Mock>(); - var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, updateRelationships: serviceMock.Object); + var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, updateRelationship: serviceMock.Object); // Act await controller.PatchRelationshipAsync(id, string.Empty, null); From 18215ce0964195dadcdaee0f12f26933f9dc4ecd Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 12:06:04 +0200 Subject: [PATCH 004/240] feat: draft description service layer / repo layer implementation --- .../Controllers/BaseJsonApiController.cs | 1 - .../Repositories/IResourceWriteRepository.cs | 2 +- .../Services/JsonApiResourceService.cs | 198 ++++++++++++------ 3 files changed, 131 insertions(+), 70 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 83b2d5229d..29a864834b 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 27e46af19e..3e945847fb 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -35,7 +35,7 @@ public interface IResourceWriteRepository /// Updates a relationship in the underlying data store. /// Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); - + /// /// Deletes a resource from the underlying data store. /// diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6036014248..6d9b809d95 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -56,6 +56,8 @@ public JsonApiResourceService( _hookExecutor = hookExecutor; } + #region Primary resource pipelines + /// public virtual async Task CreateAsync(TResource resource) { @@ -79,36 +81,7 @@ public virtual async Task CreateAsync(TResource resource) return resource; } - - /// - public virtual async Task DeleteAsync(TId id) - { - _traceWriter.LogMethodStart(new {id}); - - if (_hookExecutor != null) - { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); - } - - var succeeded = await _repository.DeleteAsync(id); - - if (_hookExecutor != null) - { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); - } - - if (!succeeded) - { - AssertPrimaryResourceExists(null); - } - } - + /// public virtual async Task> GetAsync() { @@ -199,37 +172,6 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); } - /// - // triggered by GET /articles/1/relationships/{relationshipName} - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) - { - _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - - AssertRelationshipExists(relationshipName); - - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; - - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); - - var primaryResources = await _repository.GetAsync(primaryLayer); - - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); - - if (_hookExecutor != null) - { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); - } - - return primaryResource; - } - /// // triggered by GET /articles/1/{relationshipName} public virtual async Task GetSecondaryAsync(TId id, string relationshipName) @@ -306,6 +248,97 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour return hasImplicitChanges ? afterResource : null; } + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + if (_hookExecutor != null) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); + } + + var succeeded = await _repository.DeleteAsync(id); + + if (_hookExecutor != null) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); + } + + if (!succeeded) + { + AssertPrimaryResourceExists(null); + } + } + + #endregion + + #region Relationship link pipelines + + public Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + { + /* + * APPROACH: + * - get all relationships through repository + * - construct accurate relationshipsId list + * - use repo.UpdateAsync method. POST vs PATCH part of the spec will be abstracted away from repo this way + * - EF Core: + * one-to-many: will probably iterate through list and set FK to primaryResource.id. C ~ relationshipsId.Count + * X optimal performance: we could do this without getting any data. Now it does. + * many-to-many: add new join table records. What if they already exist? + * X here we will always need to get the join table records first to make sure we are not inserting one that already exists, so no performance loss + * + * Conclusion + * => for creation we only need to fetch data if relationships is many-to-many. so for many-to-many it doesnt matter if we create reuse repo.UpdateAsync, + * or not. For to-many, we never need to fetch data, so we wont leverage this performance opportunity if we re-use repo.UpdateAsync + */ + + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + AssertRelationshipExists(relationshipName); + AssertRelationshipIsToMany(relationshipName); + + throw new NotImplementedException(); + } + + /// + // triggered by GET /articles/1/relationships/{relationshipName} + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + { + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + AssertRelationshipExists(relationshipName); + + _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); + secondaryLayer.Include = null; + + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + + var primaryResources = await _repository.GetAsync(primaryLayer); + + var primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + if (_hookExecutor != null) + { + _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); + primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); + } + + return primaryResource; + } + /// // triggered by PATCH /articles/1/relationships/{relationshipName} public virtual async Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships) @@ -347,17 +380,36 @@ public virtual async Task UpdateRelationshipAsync(TId id, string relationshipNam _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); } } - - public Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) - { - throw new NotImplementedException(); - } - + public Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) { + /* + * APPROACH ONE: + * - get all relationships through repository + * - construct accurate relationshipsId list + * - use repo.UpdateAsync method. POST vs PATCH part of the spec will be abstracted away from repo this way + * - EF Core: + * one-to-many: will probably iterate through list and set FK to primaryResource.id. C ~ amount of new ids + * X optimal performance: we could do this without getting any data. Now it does. + * many-to-many: iterates over list and creates DELETE query per removed id. C ~ amount of new ids + * X delete join table records. No need to fetch them first. Now it does. + * + * Conclusion + * => for delete we wont ever need to fetch data first. If we reuse repo.UpdateAsync, + * we wont leverage this performance opportunity + */ + + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + AssertRelationshipExists(relationshipName); + AssertRelationshipIsToMany(relationshipName); + throw new NotImplementedException(); } - + + #endregion + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -374,6 +426,16 @@ private void AssertRelationshipExists(string relationshipName) throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } } + + private void AssertRelationshipIsToMany(string relationshipName) + { + var relationship = _request.Relationship; + if (!(relationship is HasManyAttribute)) + { + // TODO: This technically is OK because we no to-many relationship was found, but we could be more specific about this + throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); + } + } private static List AsList(TResource resource) { From ec1c5a49875af3cc62c4210d812d7b344718e904 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 14:23:19 +0200 Subject: [PATCH 005/240] feat: differentiate between different types of updating relationships in service names --- .../Controllers/TodoItemsCustomController.cs | 2 +- .../Services/WorkItemService.cs | 2 +- .../JsonApiApplicationBuilder.cs | 8 +++--- .../Configuration/ServiceDiscoveryFacade.cs | 8 +++--- .../Controllers/BaseJsonApiController.cs | 28 +++++++++---------- .../Controllers/JsonApiController.cs | 16 +++++------ ...pService.cs => IAddRelationshipService.cs} | 6 ++-- .../Services/IResourceCommandService.cs | 8 +++--- ...pService.cs => ISetRelationshipService.cs} | 7 ++--- .../Services/JsonApiResourceService.cs | 22 ++++++++++----- .../BaseJsonApiController_Tests.cs | 10 +++---- .../IServiceCollectionExtensionsTests.cs | 4 +-- 12 files changed, 64 insertions(+), 57 deletions(-) rename src/JsonApiDotNetCore/Services/{ICreateRelationshipService.cs => IAddRelationshipService.cs} (55%) rename src/JsonApiDotNetCore/Services/{IUpdateRelationshipService.cs => ISetRelationshipService.cs} (52%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index fb75b37226..99c67290a6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -135,7 +135,7 @@ public async Task PatchAsync(TId id, [FromBody] T resource) [HttpPatch("{id}/relationships/{relationshipName}")] public async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) { - await _resourceService.UpdateRelationshipAsync(id, relationshipName, relationships); + await _resourceService.SetRelationshipAsync(id, relationshipName, relationships); return Ok(); } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 8eeae612c7..6598936b6f 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -66,7 +66,7 @@ public Task UpdateAsync(int id, WorkItem requestResource) throw new NotImplementedException(); } - public Task UpdateRelationshipAsync(int id, string relationshipName, object relationships) + public Task SetRelationshipAsync(int id, string relationshipName, object relationships) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 6aefff4ec3..ff540123d2 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -211,14 +211,14 @@ private void AddServiceLayer() _services.AddScoped(typeof(IDeleteService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IDeleteService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(ICreateRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(ICreateRelationshipService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(typeof(IAddRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IAddRelationshipService<,>), typeof(JsonApiResourceService<,>)); _services.AddScoped(typeof(IGetRelationshipService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IUpdateRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IUpdateRelationshipService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(typeof(ISetRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(ISetRelationshipService<,>), typeof(JsonApiResourceService<,>)); _services.AddScoped(typeof(IDeleteRelationshipService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(IDeleteRelationshipService<,>), typeof(JsonApiResourceService<,>)); diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 3fcf712930..518c5efec0 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -36,12 +36,12 @@ public class ServiceDiscoveryFacade typeof(IUpdateService<,>), typeof(IDeleteService<>), typeof(IDeleteService<,>), - typeof(ICreateRelationshipService<>), - typeof(ICreateRelationshipService<,>), + typeof(IAddRelationshipService<>), + typeof(IAddRelationshipService<,>), typeof(IGetRelationshipService<>), typeof(IGetRelationshipService<,>), - typeof(IUpdateRelationshipService<>), - typeof(IUpdateRelationshipService<,>), + typeof(ISetRelationshipService<>), + typeof(ISetRelationshipService<,>), typeof(IDeleteRelationshipService<>), typeof(IDeleteRelationshipService<,>) }; diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 29a864834b..c4b5457ce5 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -27,8 +27,8 @@ public abstract class BaseJsonApiController : CoreJsonApiControl private readonly ICreateService _create; private readonly IUpdateService _update; private readonly IDeleteService _delete; - private readonly ICreateRelationshipService _createRelationship; - private readonly IUpdateRelationshipService _updateRelationship; + private readonly IAddRelationshipService _addRelationship; + private readonly ISetRelationshipService _setRelationship; private readonly IDeleteRelationshipService _deleteRelationship; private readonly TraceLogWriter> _traceWriter; @@ -67,9 +67,9 @@ protected BaseJsonApiController( IGetSecondaryService getSecondary = null, IUpdateService update = null, IDeleteService delete = null, - ICreateRelationshipService createRelationship = null, + IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, - IUpdateRelationshipService updateRelationship = null, + ISetRelationshipService setRelationship = null, IDeleteRelationshipService deleteRelationship = null ) { @@ -83,9 +83,9 @@ protected BaseJsonApiController( _getSecondary = getSecondary; _update = update; _delete = delete; - _createRelationship = createRelationship; + _addRelationship = addRelationship; _getRelationship = getRelationship; - _updateRelationship = updateRelationship; + _setRelationship = setRelationship; _deleteRelationship = deleteRelationship; } @@ -220,21 +220,21 @@ public virtual async Task PostRelationshipAsync(TId id, string re _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_createRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _createRelationship.CreateRelationshipAsync(id, relationshipName, relationships); + if (_addRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); + await _addRelationship.AddRelationshipAsync(id, relationshipName, relationships); return Ok(); } /// - /// Updates a relationship. + /// Sets the resource(s) of a relationship. /// public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) { _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_updateRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _updateRelationship.UpdateRelationshipAsync(id, relationshipName, relationships); + if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); + await _setRelationship.SetRelationshipAsync(id, relationshipName, relationships); return Ok(); } @@ -284,13 +284,13 @@ protected BaseJsonApiController( IGetSecondaryService getSecondary = null, IUpdateService update = null, IDeleteService delete = null, - ICreateRelationshipService createRelationship = null, + IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, - IUpdateRelationshipService updateRelationship = null, + ISetRelationshipService setRelationship = null, IDeleteRelationshipService deleteRelationship = null ) : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, - createRelationship, getRelationship, updateRelationship, deleteRelationship) + addRelationship, getRelationship, setRelationship, deleteRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 3ef9219491..52eb026a23 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -35,12 +35,12 @@ public JsonApiController( IGetSecondaryService getSecondary = null, IUpdateService update = null, IDeleteService delete = null, - ICreateRelationshipService createRelationship = null, + IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, - IUpdateRelationshipService updateRelationship = null, + ISetRelationshipService setRelationship = null, IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, createRelationship, - getRelationship, updateRelationship, deleteRelationship) + : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, addRelationship, + getRelationship, setRelationship, deleteRelationship) { } #region Primary Resource Endpoints @@ -125,12 +125,12 @@ public JsonApiController( IGetSecondaryService getSecondary = null, IUpdateService update = null, IDeleteService delete = null, - ICreateRelationshipService createRelationship = null, + IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, - IUpdateRelationshipService updateRelationship = null, + ISetRelationshipService setRelationship = null, IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, createRelationship, - getRelationship, updateRelationship, deleteRelationship) + : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, addRelationship, + getRelationship, setRelationship, deleteRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs similarity index 55% rename from src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs rename to src/JsonApiDotNetCore/Services/IAddRelationshipService.cs index 54f6101afd..529adddd9b 100644 --- a/src/JsonApiDotNetCore/Services/ICreateRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs @@ -6,15 +6,15 @@ namespace JsonApiDotNetCore.Services { /// - public interface ICreateRelationshipService : ICreateRelationshipService + public interface IAddRelationshipService : IAddRelationshipService where TResource : class, IIdentifiable { } /// - public interface ICreateRelationshipService where TResource : class, IIdentifiable + public interface IAddRelationshipService where TResource : class, IIdentifiable { /// /// Handles a json:api request to add resources to a to-many relationship. /// - Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); + Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); } } diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 0fc6a64042..566b9b736a 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -10,8 +10,8 @@ public interface IResourceCommandService : ICreateService, IUpdateService, IDeleteService, - IUpdateRelationshipService, - ICreateRelationshipService, + IAddRelationshipService, + ISetRelationshipService, IDeleteRelationshipService, IResourceCommandService where TResource : class, IIdentifiable @@ -26,8 +26,8 @@ public interface IResourceCommandService : ICreateService, IUpdateService, IDeleteService, - IUpdateRelationshipService, - ICreateRelationshipService, + IAddRelationshipService, + ISetRelationshipService, IDeleteRelationshipService where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs similarity index 52% rename from src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs rename to src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 0b3b27fa9f..3d20be259c 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -4,17 +4,16 @@ namespace JsonApiDotNetCore.Services { /// - public interface IUpdateRelationshipService : IUpdateRelationshipService + public interface ISetRelationshipService : ISetRelationshipService where TResource : class, IIdentifiable { } /// - public interface IUpdateRelationshipService - where TResource : class, IIdentifiable + public interface ISetRelationshipService where TResource : class, IIdentifiable { /// /// Handles a json:api request to update an existing relationship. /// - Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships); + Task SetRelationshipAsync(TId id, string relationshipName, object relationships); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6d9b809d95..32a3942c0d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -59,6 +59,7 @@ public JsonApiResourceService( #region Primary resource pipelines /// + // triggered by POST /articles public virtual async Task CreateAsync(TResource resource) { _traceWriter.LogMethodStart(new {resource}); @@ -118,6 +119,7 @@ public virtual async Task> GetAsync() } /// + // triggered by GET /articles/{id} public virtual async Task GetAsync(TId id) { _traceWriter.LogMethodStart(new {id}); @@ -173,7 +175,7 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt } /// - // triggered by GET /articles/1/{relationshipName} + // triggered by GET /articles/{id}/{relationshipName} public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); @@ -189,7 +191,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN if (_request.IsCollection && _options.IncludeTotalResourceCount) { // TODO: Consider support for pagination links on secondary resource collection. This requires to call Count() on the inverse relationship (which may not exist). - // For /blogs/1/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount. + // For /blogs/{id}/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount. // This also means we need to invoke ResourceRepository
.CountAsync() from ResourceService. // And we should call BlogResourceDefinition.OnApplyFilter to filter out soft-deleted blogs and translate from equals('IsDeleted','false') to equals('Blog.IsDeleted','false') } @@ -216,7 +218,8 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN return secondaryResource; } - /// + /// \ + // triggered by PATCH /articles/{id} public virtual async Task UpdateAsync(TId id, TResource requestResource) { _traceWriter.LogMethodStart(new {id, requestResource}); @@ -249,6 +252,7 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour } /// + // triggered by DELETE /articles/{id public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); @@ -281,7 +285,9 @@ public virtual async Task DeleteAsync(TId id) #region Relationship link pipelines - public Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + /// + // triggered by POST /articles/{id}/relationships/{relationshipName} + public Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) { /* * APPROACH: @@ -309,7 +315,7 @@ public Task CreateRelationshipAsync(TId id, string relationshipName, IEnumerable } /// - // triggered by GET /articles/1/relationships/{relationshipName} + // triggered by GET /articles/{id}/relationships/{relationshipName} public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); @@ -340,8 +346,8 @@ public virtual async Task GetRelationshipAsync(TId id, string relatio } /// - // triggered by PATCH /articles/1/relationships/{relationshipName} - public virtual async Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships) + // triggered by PATCH /articles/{id}/relationships/{relationshipName} + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationships) { _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); @@ -381,6 +387,8 @@ public virtual async Task UpdateRelationshipAsync(TId id, string relationshipNam } } + /// + // triggered by DELETE /articles/{id}/relationships/{relationshipName} public Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) { /* diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 7704b08f9d..842e1558da 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -41,10 +41,10 @@ public ResourceController( IGetRelationshipService getRelationship = null, ICreateService create = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationship = null, + ISetRelationshipService setRelationship = null, IDeleteService delete = null) : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, - update, updateRelationship, delete) + update, setRelationship, delete) { } } @@ -221,14 +221,14 @@ public async Task PatchRelationshipsAsync_Calls_Service() { // Arrange const int id = 0; - var serviceMock = new Mock>(); - var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, updateRelationship: serviceMock.Object); + var serviceMock = new Mock>(); + var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, setRelationship: serviceMock.Object); // Act await controller.PatchRelationshipAsync(id, string.Empty, null); // Assert - serviceMock.Verify(m => m.UpdateRelationshipAsync(id, string.Empty, null), Times.Once); + serviceMock.Verify(m => m.SetRelationshipAsync(id, string.Empty, null), Times.Once); } [Fact] diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index dc3931d429..a9131d2f54 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -167,7 +167,7 @@ private class IntResourceService : IResourceService public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); - public Task UpdateRelationshipAsync(int id, string relationshipName, object relationships) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object relationships) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -179,7 +179,7 @@ private class GuidResourceService : IResourceService public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); - public Task UpdateRelationshipAsync(Guid id, string relationshipName, object relationships) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object relationships) => throw new NotImplementedException(); } From c12d4e1c3655560cd0ac3bb5a934db35c24d74da Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 14:32:34 +0200 Subject: [PATCH 006/240] tests: rename relationship tests to 'setting' rather than 'updating' --- .../Spec/UpdatingRelationshipsTests.cs | 274 +++++++++--------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 5764b77542..29285f0bf2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -424,94 +424,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, updatedTodoItems.Count); } - - [Fact] - public async Task Can_Update_ToMany_Relationship_ThroughLink() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new - { - data = new List - { - new { - type = "todoItems", - id = $"{todoItem.Id}" - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - _context = _fixture.GetRequiredService(); - var personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; - - Assert.NotEmpty(personsTodoItems); - } - - [Fact] - public async Task Can_Update_ToOne_Relationship_ThroughLink() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - var todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(todoItemsOwner); - } - + [Fact] public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() { @@ -614,52 +527,7 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Empty(personResult.TodoItems); } - - [Fact] - public async Task Can_Delete_Relationship_By_Patching_Relationship() - { - // Arrange - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - - _context.People.Add(person); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new - { - data = (object)null - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - var todoItemResult = _context.TodoItems - .AsNoTracking() - .Include(t => t.Owner) - .Single(t => t.Id == todoItem.Id); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Null(todoItemResult.Owner); - } - + [Fact] public async Task Updating_ToOne_Relationship_With_Implicit_Remove() { @@ -771,7 +639,139 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() } [Fact] - public async Task Fails_On_Unknown_Relationship() + public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() + { + // Arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + await _context.SaveChangesAsync(); + + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new List + { + new { + type = "todoItems", + id = $"{todoItem.Id}" + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + _context = _fixture.GetRequiredService(); + var personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; + + Assert.NotEmpty(personsTodoItems); + } + + [Fact] + public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() + { + // Arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + await _context.SaveChangesAsync(); + + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(person); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + var todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(todoItemsOwner); + } + + [Fact] + public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpoint() + { + // Arrange + var person = _personFaker.Generate(); + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + + _context.People.Add(person); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = (object)null + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + // Assert + var todoItemResult = _context.TodoItems + .AsNoTracking() + .Include(t => t.Owner) + .Single(t => t.Id == todoItem.Id); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(todoItemResult.Owner); + } + + [Fact] + public async Task Fails_On_Unknown_Relationship_On_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); @@ -809,9 +809,9 @@ public async Task Fails_On_Unknown_Relationship() Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } - + [Fact] - public async Task Fails_On_Missing_Resource() + public async Task Fails_On_Missing_Resource_On_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); From 3fea0adb61ce0e876b0f0177c01b322eeeff05e3 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 14:42:52 +0200 Subject: [PATCH 007/240] tests: add POST and DELETE relationship link endpoint tests --- .../Spec/UpdatingRelationshipsTests.cs | 114 +++++++++++++++++- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 29285f0bf2..baf3be0281 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -643,6 +643,7 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); + person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); _context.People.Add(person); var todoItem = _todoItemFaker.Generate(); @@ -680,15 +681,15 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() var response = await client.SendAsync(request); // Assert - var body = response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - _context = _fixture.GetRequiredService(); - var personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; + var assertTodoItems = _context.People.Include(p => p.TodoItems) + .Single(p => p.Id == person.Id).TodoItems; - Assert.NotEmpty(personsTodoItems); + Assert.Single(assertTodoItems); + Assert.Equal(todoItem.Id, assertTodoItems.ElementAt(0).Id); } - + [Fact] public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() { @@ -770,6 +771,109 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo Assert.Null(todoItemResult.Owner); } + [Fact] + public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() + { + // Arrange + var person = _personFaker.Generate(); + person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + await _context.SaveChangesAsync(); + + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new List + { + new { + type = "todoItems", + id = $"{todoItem.Id}" + } + } + }; + + var httpMethod = new HttpMethod("POST"); + var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _context = _fixture.GetRequiredService(); + var assertTodoItems = _context.People.Include(p => p.TodoItems) + .Single(p => p.Id == person.Id).TodoItems; + + Assert.Equal(4, assertTodoItems.Count); + Assert.Equal(todoItem.Id, assertTodoItems.ElementAt(3).Id); + } + + [Fact] + public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_Endpoint() + { + // Arrange + var person = _personFaker.Generate(); + person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + _context.People.Add(person); + + await _context.SaveChangesAsync(); + var todoItemToDelete = person.TodoItems.ElementAt(0); + + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new List + { + new { + type = "todoItems", + id = $"{todoItemToDelete.Id}" + } + } + }; + + var httpMethod = new HttpMethod("DELETE"); + var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _context = _fixture.GetRequiredService(); + var assertTodoItems = _context.People.Include(p => p.TodoItems) + .Single(p => p.Id == person.Id).TodoItems; + + Assert.Equal(2, assertTodoItems.Count); + var deletedTodoItem = assertTodoItems.SingleOrDefault(ti => ti.Id == todoItemToDelete.Id); + Assert.Null(deletedTodoItem); + } + [Fact] public async Task Fails_On_Unknown_Relationship_On_Relationship_Endpoint() { From e0c62d42325f91af65486387965786dde0e3cb18 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 14:44:26 +0200 Subject: [PATCH 008/240] chore: minor rename in tests --- .../Acceptance/Spec/UpdatingRelationshipsTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index baf3be0281..b6bf9d4b76 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -875,7 +875,7 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En } [Fact] - public async Task Fails_On_Unknown_Relationship_On_Relationship_Endpoint() + public async Task Fails_When_Unknown_Relationship_On_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); @@ -915,7 +915,7 @@ public async Task Fails_On_Unknown_Relationship_On_Relationship_Endpoint() } [Fact] - public async Task Fails_On_Missing_Resource_On_Relationship_Endpoint() + public async Task Fails_When_Missing_Resource_On_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); From c8abe273cdc9dc35c4fe473311c63cd9b08a53ce Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 14:57:30 +0200 Subject: [PATCH 009/240] chore: removed some code duplication in service registration --- .../JsonApiApplicationBuilder.cs | 62 ++++++------------- .../Configuration/ServiceDiscoveryFacade.cs | 4 +- 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index ff540123d2..d650b58969 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -184,53 +184,29 @@ private void AddResourceLayer() private void AddRepositoryLayer() { - _services.AddScoped(typeof(IResourceRepository<>), typeof(EntityFrameworkCoreRepository<>)); - _services.AddScoped(typeof(IResourceRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); - - _services.AddScoped(typeof(IResourceReadRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); + var intTypedResourceService = typeof(EntityFrameworkCoreRepository<>); + var openTypedResourceService = typeof(EntityFrameworkCoreRepository<,>); + + foreach (var partialRepositoryInterface in ServiceDiscoveryFacade.RepositoryInterfaces) + { + _services.AddScoped(partialRepositoryInterface, + partialRepositoryInterface.GetGenericArguments().Length == 2 + ? openTypedResourceService + : intTypedResourceService); + } } private void AddServiceLayer() { - _services.AddScoped(typeof(ICreateService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(ICreateService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetAllService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetAllService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetByIdService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetByIdService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetSecondaryService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetSecondaryService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IUpdateService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IUpdateService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IDeleteService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IDeleteService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IAddRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IAddRelationshipService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(ISetRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(ISetRelationshipService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IDeleteRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IDeleteRelationshipService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IResourceService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IResourceService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IResourceQueryService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IResourceQueryService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IResourceCommandService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IResourceCommandService<,>), typeof(JsonApiResourceService<,>)); + var intTypedResourceService = typeof(JsonApiResourceService<>); + var openTypedResourceService = typeof(JsonApiResourceService<,>); + foreach (var partialServiceInterface in ServiceDiscoveryFacade.ServiceInterfaces) + { + _services.AddScoped(partialServiceInterface, + partialServiceInterface.GetGenericArguments().Length == 2 + ? openTypedResourceService + : intTypedResourceService); + } } private void AddQueryStringLayer() diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 518c5efec0..ddae1efc20 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -46,7 +46,7 @@ public class ServiceDiscoveryFacade typeof(IDeleteRelationshipService<,>) }; - private static readonly HashSet _repositoryInterfaces = new HashSet { + internal static readonly HashSet RepositoryInterfaces = new HashSet { typeof(IResourceRepository<>), typeof(IResourceRepository<,>), typeof(IResourceWriteRepository<>), @@ -174,7 +174,7 @@ private void AddServices(Assembly assembly, ResourceDescriptor resourceDescripto private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) { - foreach (var repositoryInterface in _repositoryInterfaces) + foreach (var repositoryInterface in RepositoryInterfaces) { RegisterImplementations(assembly, repositoryInterface, resourceDescriptor); } From 1b862c980dae42f0564d2028c62fe9d2048dd2c0 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 16:39:04 +0200 Subject: [PATCH 010/240] chore: reveal bug --- .../IRepositoryRelationshipUpdateHelper.cs | 4 +- .../Services/JsonApiResourceService.cs | 9 ++++ .../Spec/UpdatingRelationshipsTests.cs | 50 ++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs index 376644cb2e..a67d408e38 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCore.Repositories { /// - /// A special helper that processes updates of relationships + /// A helper dedicated to processing updates of relationships /// /// - /// This service required to be able translate involved expressions into queries + /// This service is required to be able translate involved expressions into queries /// instead of having them evaluated on the client side. In particular, for all three types of relationship /// a lookup is performed based on an ID. Expressions that use IIdentifiable.StringId can never /// be translated into queries because this property only exists at runtime after the query is performed. diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 32a3942c0d..9189ccfc38 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -303,6 +303,15 @@ public Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable for creation we only need to fetch data if relationships is many-to-many. so for many-to-many it doesnt matter if we create reuse repo.UpdateAsync, * or not. For to-many, we never need to fetch data, so we wont leverage this performance opportunity if we re-use repo.UpdateAsync + * + * + * Rectification: + * before adding a relationship we need to see if it acutally exists. If not we must return 404. + * + * + * So new conclusion: we always need to fetch the to be added or deleted relationship + * we dont have to fetch the current state of the relationship. + * unless many-to-many: I think query wil fail if we create double entry in jointable when the pre-existing entry is not being tracked in dbcontext. */ _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index b6bf9d4b76..1918f3c1e7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -215,6 +215,54 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi Assert.Equal(todoItem.Id, updatedTodoItem.ParentTodoId); } + + [Fact] + public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_Resource() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + // Act + var content = new + { + data = new + { + type = "todoItems", + id = todoItem.Id, + relationships = new Dictionary + { + { "owner", new + { + data = new { type = "people", id = "999999999" } + } + } + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}"; + var request = new HttpRequestMessage(httpMethod, route); + + string serializedContent = JsonConvert.SerializeObject(content); + request.Content = new StringContent(serializedContent); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + + // Act + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + } [Fact] public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() @@ -725,7 +773,7 @@ public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(todoItemsOwner); } - + [Fact] public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpoint() { From 626aa4f2209e37026376b5a5212c7018aaccbaa9 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 19:37:12 +0200 Subject: [PATCH 011/240] fix: #852 --- .../Services/CustomArticleService.cs | 5 +- .../JsonApiApplicationBuilder.cs | 2 + .../Configuration/MethodInfoExtensions.cs | 19 +++ .../Errors/ResourceNotFoundException.cs | 35 +++- .../Repositories/IResourceAccessor.cs | 80 +++++++++ .../Services/JsonApiResourceService.cs | 72 +++++++- .../Spec/UpdatingRelationshipsTests.cs | 159 +++++++++++------- 7 files changed, 297 insertions(+), 75 deletions(-) create mode 100644 src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 430b65e27e..f03e2cb600 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -22,9 +22,12 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, + IResourceAccessor resourceAccessor, + ITargetedFields targetedFields, + IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + resourceChangeTracker, resourceFactory, resourceAccessor, targetedFields, provider, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index d650b58969..ffded9c039 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -194,6 +194,8 @@ private void AddRepositoryLayer() ? openTypedResourceService : intTypedResourceService); } + + _services.AddScoped(); } private void AddServiceLayer() diff --git a/src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs b/src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs new file mode 100644 index 0000000000..b1d8e17195 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs @@ -0,0 +1,19 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.Configuration +{ + public static class MethodInfoExtensions + { + public static async Task InvokeAsync(this MethodInfo methodInfo, object obj, params object[] parameters) + { + if (methodInfo == null) throw new ArgumentNullException(nameof(methodInfo)); + + var task = (Task)methodInfo.Invoke(obj, parameters); + await task.ConfigureAwait(false); + var resultProperty = task.GetType().GetProperty("Result"); + return resultProperty.GetValue(task); + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 22f6a57eaa..b33a1f0b97 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using JsonApiDotNetCore.Serialization.Objects; @@ -8,12 +11,36 @@ namespace JsonApiDotNetCore.Errors /// public sealed class ResourceNotFoundException : JsonApiException { - public ResourceNotFoundException(string resourceId, string resourceType) : base(new Error(HttpStatusCode.NotFound) + public ResourceNotFoundException(string resourceId, string resourceType) : base( + new Error(HttpStatusCode.NotFound) + { + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." + }) { } + + public ResourceNotFoundException(Dictionary> nonExistingResources) : base( + new Error(HttpStatusCode.NotFound) + { + Title = "The requested resources do not exist.", + Detail = CreateErrorMessageForMultipleMissing(nonExistingResources) + }) { - Title = "The requested resource does not exist.", - Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." - }) + var pairs = nonExistingResources.ToList(); + if (pairs.Count == 1 && pairs[0].Value.Count == 1) + { + var (resourceType, value) = pairs[0]; + var resourceId = value.First(); + + throw new ResourceNotFoundException(resourceId, resourceType); + } + } + + private static string CreateErrorMessageForMultipleMissing(Dictionary> missingResources) { + var errorDetailLines = missingResources.Select(p => $"{p.Key}: {string.Join(',', p.Value)}") + .ToArray(); + + return $@"For the following types, the resources with the specified ids do not exist:\n{string.Join('\n', errorDetailLines)}"; } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs new file mode 100644 index 0000000000..14bc56dfca --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Repositories +{ + /// + public interface IResourceAccessor + { + Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids); + } + + /// + public class ResourceAccessor : IResourceAccessor + { + private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); + private static readonly MethodInfo _accessorMethod; + private readonly IServiceProvider _serviceProvider; + private readonly IResourceContextProvider _provider; + + static ResourceAccessor() + { + _accessorMethod = typeof(ResourceAccessor).GetMethod(nameof(Accessor), BindingFlags.NonPublic | BindingFlags.Static); + } + + private static async Task> Accessor( + IEnumerable ids, + IResourceReadRepository repository, + ResourceContext resourceContext) + where TResource : class, IIdentifiable + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + + var queryLayer = new QueryLayer(resourceContext) + { + Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), + ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) + }; + + return await repository.GetAsync(queryLayer); + } + + public ResourceAccessor(IServiceProvider serviceProvider, IResourceContextProvider provider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); + _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); + } + + /// + public async Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids) + { + var resourceContext = _provider.GetResourceContext(resourceType); + var repository = GetRepository(resourceType, resourceContext.IdentityType); + + var parameterizedAccessor = _accessorMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); + var resources = (IEnumerable) await parameterizedAccessor.InvokeAsync(null, new[] {ids, repository, resourceContext}); + + var result = ids.Select(id => resources.FirstOrDefault(r => r.StringId == id) ).ToArray(); + + return result; + } + + private object GetRepository(Type resourceType, Type identifierType) + { + var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, identifierType); + var repository = _serviceProvider.GetRequiredService(repositoryType); + + return repository; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 9189ccfc38..f3054d40ff 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -30,6 +30,9 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; + private readonly IResourceAccessor _resourceAccessor; + private readonly ITargetedFields _targetedFields; + private readonly IResourceContextProvider _provider; private readonly IResourceHookExecutor _hookExecutor; public JsonApiResourceService( @@ -41,6 +44,9 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, + IResourceAccessor resourceAccessor, + ITargetedFields targetedFields, + IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -53,6 +59,9 @@ public JsonApiResourceService( _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _resourceAccessor = resourceAccessor ?? throw new ArgumentNullException(nameof(resourceAccessor)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _hookExecutor = hookExecutor; } @@ -224,7 +233,12 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour { _traceWriter.LogMethodStart(new {id, requestResource}); if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - + + if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) + { + await AssertRelationshipValuesExistAsync(assignments); + } + TResource databaseResource = await GetPrimaryResourceById(id, false); _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); @@ -250,7 +264,7 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); return hasImplicitChanges ? afterResource : null; } - + /// // triggered by DELETE /articles/{id public virtual async Task DeleteAsync(TId id) @@ -362,7 +376,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); secondaryLayer.Include = null; @@ -427,6 +441,23 @@ public Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable #endregion + private bool HasNonNullRelationshipAssignments(TResource requestResource, out IEnumerable<(RelationshipAttribute, object)> assignments) + { + assignments = _targetedFields.Relationships + .Select(attr => (attr, attr.GetValue(requestResource))) + .Where(t => + { + if (t.Item1 is HasOneAttribute) + { + return t.Item2 != null; + } + + return ((IEnumerable) t.Item2).Any(); + }); + + return assignments.Any(); + } + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -453,8 +484,36 @@ private void AssertRelationshipIsToMany(string relationshipName) throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } } + + private async Task AssertRelationshipValuesExistAsync(IEnumerable<(RelationshipAttribute relationship, object relationshipValue)> assignments) + { + var nonExistingResources = new Dictionary>(); + foreach (var (relationship, relationshipValue) in assignments) + { + IEnumerable identifiers; + if (relationshipValue is IIdentifiable identifiable) + { + identifiers = new [] { identifiable.StringId }; + } + else + { + identifiers = ((IEnumerable) relationshipValue).Select(i => i.StringId); + } + var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); + var missing = identifiers.Where(id => resources.All(r => r?.StringId != id)).ToArray(); + if (missing.Any()) + { + nonExistingResources.Add(_provider.GetResourceContext(relationship.RightType).PublicName, missing.ToList()); + } + } + + if (nonExistingResources.Any()) + { + throw new ResourceNotFoundException(nonExistingResources); + } + } - private static List AsList(TResource resource) + private List AsList(TResource resource) { return new List { resource }; } @@ -477,9 +536,12 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, + IResourceAccessor resourceAccessor, + ITargetedFields targetedFields, + IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + resourceChangeTracker, resourceFactory, resourceAccessor, targetedFields, provider, hookExecutor) { } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 1918f3c1e7..2fcda0f0c1 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -67,14 +67,14 @@ public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() id = todoItem.Id, relationships = new Dictionary { - { "childrenTodos", new + { + "childrenTodos", new { data = new object[] { - new { type = "todoItems", id = $"{todoItem.Id}" }, - new { type = "todoItems", id = $"{strayTodoItem.Id}" } + new {type = "todoItems", id = $"{todoItem.Id}"}, + new {type = "todoItems", id = $"{strayTodoItem.Id}"} } - } } } @@ -95,8 +95,8 @@ public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() _context = _fixture.GetRequiredService(); var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.ChildrenTodos).First(); + .Where(ti => ti.Id == todoItem.Id) + .Include(ti => ti.ChildrenTodos).First(); Assert.Contains(updatedTodoItem.ChildrenTodos, ti => ti.Id == todoItem.Id); } @@ -124,9 +124,10 @@ public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() id = todoItem.Id, relationships = new Dictionary { - { "dependentOnTodo", new + { + "dependentOnTodo", new { - data = new { type = "todoItems", id = $"{todoItem.Id}" } + data = new {type = "todoItems", id = $"{todoItem.Id}"} } } } @@ -147,8 +148,8 @@ public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() _context = _fixture.GetRequiredService(); var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.DependentOnTodo).First(); + .Where(ti => ti.Id == todoItem.Id) + .Include(ti => ti.DependentOnTodo).First(); Assert.Equal(todoItem.Id, updatedTodoItem.DependentOnTodoId); } @@ -178,17 +179,19 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi id = todoItem.Id, relationships = new Dictionary { - { "dependentOnTodo", new + { + "dependentOnTodo", new { - data = new { type = "todoItems", id = $"{todoItem.Id}" } + data = new {type = "todoItems", id = $"{todoItem.Id}"} } }, - { "childrenTodos", new + { + "childrenTodos", new { data = new object[] { - new { type = "todoItems", id = $"{todoItem.Id}" }, - new { type = "todoItems", id = $"{strayTodoItem.Id}" } + new {type = "todoItems", id = $"{todoItem.Id}"}, + new {type = "todoItems", id = $"{strayTodoItem.Id}"} } } } @@ -210,18 +213,19 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi _context = _fixture.GetRequiredService(); var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.ParentTodo).First(); + .Where(ti => ti.Id == todoItem.Id) + .Include(ti => ti.ParentTodo).First(); Assert.Equal(todoItem.Id, updatedTodoItem.ParentTodoId); } - + [Fact] public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_Resource() { // Arrange var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); + var person = _personFaker.Generate(); + _context.AddRange(todoItem, person); await _context.SaveChangesAsync(); var builder = WebHost.CreateDefaultBuilder() @@ -239,9 +243,21 @@ public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_R id = todoItem.Id, relationships = new Dictionary { - { "owner", new + { + "stakeHolders", new + { + data = new[] + { + new { type = "people", id = person.StringId }, + new { type = "people", id = "900000" }, + new { type = "people", id = "900001" } + } + } + }, + { + "parentTodo", new { - data = new { type = "people", id = "999999999" } + data = new { type = "todoItems", id = "900002" } } } } @@ -256,12 +272,11 @@ public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_R request.Content = new StringContent(serializedContent); request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - // Act var response = await client.SendAsync(request); - + var responseBody = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - + Assert.Contains("For the following types, the resources with the specified ids do not exist:\\\\npeople: 900000,900001\\ntodoItems: 900002\"", responseBody); } [Fact] @@ -295,14 +310,14 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() id = todoCollection.Id, relationships = new Dictionary { - { "todoItems", new + { + "todoItems", new { data = new object[] { - new { type = "todoItems", id = $"{newTodoItem1.Id}" }, - new { type = "todoItems", id = $"{newTodoItem2.Id}" } + new {type = "todoItems", id = $"{newTodoItem1.Id}"}, + new {type = "todoItems", id = $"{newTodoItem2.Id}"} } - } } } @@ -373,14 +388,14 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe }, relationships = new Dictionary { - { "todoItems", new + { + "todoItems", new { data = new object[] { - new { type = "todoItems", id = $"{newTodoItem1.Id}" }, - new { type = "todoItems", id = $"{newTodoItem2.Id}" } + new {type = "todoItems", id = $"{newTodoItem1.Id}"}, + new {type = "todoItems", id = $"{newTodoItem2.Id}"} } - } } } @@ -437,14 +452,14 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl id = todoCollection.Id, relationships = new Dictionary { - { "todoItems", new + { + "todoItems", new { data = new object[] { - new { type = "todoItems", id = $"{todoItem1.Id}" }, - new { type = "todoItems", id = $"{todoItem2.Id}" } + new {type = "todoItems", id = $"{todoItem1.Id}"}, + new {type = "todoItems", id = $"{todoItem2.Id}"} } - } } } @@ -472,7 +487,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, updatedTodoItems.Count); } - + [Fact] public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() { @@ -501,7 +516,7 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() { owner = new { - data = (object)null + data = (object) null } } } @@ -534,7 +549,7 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() // Arrange var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); - person.TodoItems = new HashSet { todoItem }; + person.TodoItems = new HashSet {todoItem}; _context.People.Add(person); await _context.SaveChangesAsync(); @@ -546,7 +561,8 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() type = "people", relationships = new Dictionary { - { "todoItems", new + { + "todoItems", new { data = new List() } @@ -575,7 +591,7 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Empty(personResult.TodoItems); } - + [Fact] public async Task Updating_ToOne_Relationship_With_Implicit_Remove() { @@ -585,7 +601,7 @@ public async Task Updating_ToOne_Relationship_With_Implicit_Remove() var person1 = _personFaker.Generate(); person1.Passport = passport; var person2 = _personFaker.Generate(); - context.People.AddRange(new List { person1, person2 }); + context.People.AddRange(new List {person1, person2}); await context.SaveChangesAsync(); var passportId = person1.PassportId; var content = new @@ -596,9 +612,10 @@ public async Task Updating_ToOne_Relationship_With_Implicit_Remove() id = person2.Id, relationships = new Dictionary { - { "passport", new + { + "passport", new { - data = new { type = "passports", id = $"{passport.StringId}" } + data = new {type = "passports", id = $"{passport.StringId}"} } } } @@ -618,9 +635,11 @@ public async Task Updating_ToOne_Relationship_With_Implicit_Remove() var body = await response.Content.ReadAsStringAsync(); // Assert - - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("Passport").FirstOrDefault(); + + Assert.True(HttpStatusCode.OK == response.StatusCode, + $"{route} returned {response.StatusCode} status code with payload: {body}"); + var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("Passport") + .FirstOrDefault(); Assert.Equal(passportId, dbPerson.Passport.Id); } @@ -633,7 +652,7 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); var person2 = _personFaker.Generate(); person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - context.People.AddRange(new List { person1, person2 }); + context.People.AddRange(new List {person1, person2}); await context.SaveChangesAsync(); var todoItem1Id = person1.TodoItems.ElementAt(0).Id; var todoItem2Id = person1.TodoItems.ElementAt(1).Id; @@ -646,15 +665,18 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() id = person2.Id, relationships = new Dictionary { - { "todoItems", new + { + "todoItems", new { data = new List { - new { + new + { type = "todoItems", id = $"{todoItem1Id}" }, - new { + new + { type = "todoItems", id = $"{todoItem2Id}" } @@ -679,8 +701,10 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() // Assert - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("TodoItems").FirstOrDefault(); + Assert.True(HttpStatusCode.OK == response.StatusCode, + $"{route} returned {response.StatusCode} status code with payload: {body}"); + var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("TodoItems") + .FirstOrDefault(); Assert.Equal(2, dbPerson.TodoItems.Count); Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem1Id)); Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem2Id)); @@ -709,7 +733,8 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() { data = new List { - new { + new + { type = "todoItems", id = $"{todoItem.Id}" } @@ -737,7 +762,7 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() Assert.Single(assertTodoItems); Assert.Equal(todoItem.Id, assertTodoItems.ElementAt(0).Id); } - + [Fact] public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() { @@ -794,7 +819,7 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo var content = new { - data = (object)null + data = (object) null }; var httpMethod = new HttpMethod("PATCH"); @@ -818,7 +843,7 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Null(todoItemResult.Owner); } - + [Fact] public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() { @@ -842,7 +867,8 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() { data = new List { - new { + new + { type = "todoItems", id = $"{todoItem.Id}" } @@ -870,7 +896,7 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() Assert.Equal(4, assertTodoItems.Count); Assert.Equal(todoItem.Id, assertTodoItems.ElementAt(3).Id); } - + [Fact] public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_Endpoint() { @@ -878,7 +904,7 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); _context.People.Add(person); - + await _context.SaveChangesAsync(); var todoItemToDelete = person.TodoItems.ElementAt(0); @@ -892,7 +918,8 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En { data = new List { - new { + new + { type = "todoItems", id = $"{todoItemToDelete.Id}" } @@ -921,7 +948,7 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En var deletedTodoItem = assertTodoItems.SingleOrDefault(ti => ti.Id == todoItemToDelete.Id); Assert.Null(deletedTodoItem); } - + [Fact] public async Task Fails_When_Unknown_Relationship_On_Relationship_Endpoint() { @@ -959,9 +986,10 @@ public async Task Fails_When_Unknown_Relationship_On_Relationship_Endpoint() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.", + errorDocument.Errors[0].Detail); } - + [Fact] public async Task Fails_When_Missing_Resource_On_Relationship_Endpoint() { @@ -996,7 +1024,8 @@ public async Task Fails_When_Missing_Resource_On_Relationship_Endpoint() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.", + errorDocument.Errors[0].Detail); } } } From d30780da5164637a624af6b7eb98e4f3073c66f2 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 19:55:21 +0200 Subject: [PATCH 012/240] feat: improved error for POST non-tomany and DELETE non-tomany (relationship links) --- .../Errors/RequestMethodNotAllowedException.cs | 10 +++++++++- .../Services/JsonApiResourceService.cs | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs index 7444d6cc46..ca99e0608c 100644 --- a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Errors @@ -15,10 +16,17 @@ public RequestMethodNotAllowedException(HttpMethod method) : base(new Error(HttpStatusCode.MethodNotAllowed) { Title = "The request method is not allowed.", - Detail = $"Resource does not support {method} requests." + Detail = $"Resource does not support {method} requests." }) { Method = method; } + + public RequestMethodNotAllowedException(string mismatchingRelationshipType) + : base(new Error(HttpStatusCode.MethodNotAllowed) + { + Title = "The request method is not allowed.", + Detail = $"Relationship {mismatchingRelationshipType} is not a to-many relationship." + }) { } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index f3054d40ff..d2b834e71d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -480,8 +481,7 @@ private void AssertRelationshipIsToMany(string relationshipName) var relationship = _request.Relationship; if (!(relationship is HasManyAttribute)) { - // TODO: This technically is OK because we no to-many relationship was found, but we could be more specific about this - throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); + throw new RequestMethodNotAllowedException(relationship.PublicName); } } From 1644d37f5d95c23675df4ee3a844ca8516d8f9f8 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 4 Oct 2020 20:16:49 +0200 Subject: [PATCH 013/240] feat: to many assertions in POST DELETE PATCH relationship links --- .../Services/JsonApiResourceService.cs | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index d2b834e71d..29637bdb91 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -67,7 +67,7 @@ public JsonApiResourceService( } #region Primary resource pipelines - + /// // triggered by POST /articles public virtual async Task CreateAsync(TResource resource) @@ -92,7 +92,7 @@ public virtual async Task CreateAsync(TResource resource) return resource; } - + /// public virtual async Task> GetAsync() { @@ -235,13 +235,13 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour _traceWriter.LogMethodStart(new {id, requestResource}); if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + TResource databaseResource = await GetPrimaryResourceById(id, false); + if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) { await AssertRelationshipValuesExistAsync(assignments); } - TResource databaseResource = await GetPrimaryResourceById(id, false); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); _resourceChangeTracker.SetRequestedAttributeValues(requestResource); @@ -265,7 +265,7 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); return hasImplicitChanges ? afterResource : null; } - + /// // triggered by DELETE /articles/{id public virtual async Task DeleteAsync(TId id) @@ -297,12 +297,12 @@ public virtual async Task DeleteAsync(TId id) } #endregion - + #region Relationship link pipelines /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) { /* * APPROACH: @@ -335,6 +335,9 @@ public Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) { /* * APPROACH ONE: @@ -437,12 +440,15 @@ public Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(relationshipName); + var relationship = GetRelationshipAttribute(relationshipName); + await AssertRelationshipValuesExistAsync((relationship, relationships)); + throw new NotImplementedException(); } - + #endregion - - private bool HasNonNullRelationshipAssignments(TResource requestResource, out IEnumerable<(RelationshipAttribute, object)> assignments) + + private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) { assignments = _targetedFields.Relationships .Select(attr => (attr, attr.GetValue(requestResource))) @@ -454,11 +460,16 @@ private bool HasNonNullRelationshipAssignments(TResource requestResource, out IE } return ((IEnumerable) t.Item2).Any(); - }); + }).ToArray(); return assignments.Any(); } - + + private RelationshipAttribute GetRelationshipAttribute(string relationshipName) + { + return _provider.GetResourceContext().Relationships.Single(attr => attr.PublicName == relationshipName); + } + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -475,7 +486,7 @@ private void AssertRelationshipExists(string relationshipName) throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } } - + private void AssertRelationshipIsToMany(string relationshipName) { var relationship = _request.Relationship; @@ -484,8 +495,8 @@ private void AssertRelationshipIsToMany(string relationshipName) throw new RequestMethodNotAllowedException(relationship.PublicName); } } - - private async Task AssertRelationshipValuesExistAsync(IEnumerable<(RelationshipAttribute relationship, object relationshipValue)> assignments) + + private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttribute relationship, object relationshipValue)[] assignments) { var nonExistingResources = new Dictionary>(); foreach (var (relationship, relationshipValue) in assignments) @@ -498,7 +509,8 @@ private async Task AssertRelationshipValuesExistAsync(IEnumerable<(RelationshipA else { identifiers = ((IEnumerable) relationshipValue).Select(i => i.StringId); - } + } + var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); var missing = identifiers.Where(id => resources.All(r => r?.StringId != id)).ToArray(); if (missing.Any()) From 0c69abcfa45a8b2f3e0c28c6b2956da33a364c1f Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 5 Oct 2020 18:41:47 +0200 Subject: [PATCH 014/240] chore: cleanup in repository --- .../Controllers/BaseJsonApiController.cs | 4 +- .../Controllers/JsonApiController.cs | 9 - .../Repositories/DbContextExtensions.cs | 21 ++- .../EntityFrameworkCoreRepository.cs | 160 ++++++++++-------- .../Repositories/IResourceAccessor.cs | 74 +------- .../Repositories/IResourceWriteRepository.cs | 2 +- .../Repositories/ResourceAccessor.cs | 78 +++++++++ .../Client/Internal/RequestSerializer.cs | 1 + .../Services/JsonApiResourceService.cs | 24 ++- .../Acceptance/Spec/CreatingDataTests.cs | 3 +- .../Acceptance/Spec/UpdatingDataTests.cs | 3 +- .../Spec/UpdatingRelationshipsTests.cs | 6 +- .../ResultCapturingRepository.cs | 3 +- 13 files changed, 218 insertions(+), 170 deletions(-) create mode 100644 src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index c4b5457ce5..4e14a08a1d 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -40,7 +40,7 @@ protected BaseJsonApiController( ILoggerFactory loggerFactory, IResourceService resourceService) : this(options, loggerFactory, resourceService, resourceService, resourceService, resourceService, - resourceService, resourceService, resourceService, resourceService) + resourceService, resourceService, resourceService, resourceService, resourceService, resourceService) { } /// @@ -52,7 +52,7 @@ protected BaseJsonApiController( IResourceQueryService queryService = null, IResourceCommandService commandService = null) : this(options, loggerFactory, commandService, queryService, queryService, queryService, commandService, - commandService, commandService, queryService) + commandService, commandService, queryService, commandService, commandService) { } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 52eb026a23..8aa98d04ba 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -43,8 +43,6 @@ public JsonApiController( getRelationship, setRelationship, deleteRelationship) { } - #region Primary Resource Endpoints - /// [HttpPost] public override async Task PostAsync([FromBody] TResource resource) @@ -75,10 +73,6 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); - #endregion - - #region Relationship Link Endpoints - /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( @@ -99,9 +93,6 @@ public override async Task PatchRelationshipAsync(TId id, string [HttpDelete("{id}/relationships/{relationshipName}")] public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) => await base.DeleteRelationshipAsync(id, relationshipName, relationships); - - #endregion - } /// diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index ebc1ec6498..7d8c485b1f 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -16,16 +16,27 @@ public static class DbContextExtensions internal static TEntity GetTrackedEntity(this DbContext context, TEntity entity) where TEntity : class, IIdentifiable { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + return (TEntity)context.GetTrackedEntity(typeof(TEntity), entity.StringId); + } + + internal static IIdentifiable GetTrackedEntity(this DbContext context, Type entityType, string id) + { + if (entityType == null) throw new ArgumentNullException(nameof(entityType)); + + if (id == null) + { + return null; + } var entityEntry = context.ChangeTracker .Entries() .FirstOrDefault(entry => - entry.Entity.GetType() == entity.GetType() && - ((IIdentifiable) entry.Entity).StringId == entity.StringId); + entry.Entity.GetType() == entityType && + ((IIdentifiable) entry.Entity).StringId == id); - return (TEntity) entityEntry?.Entity; + return (IIdentifiable)entityEntry?.Entity; } /// diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 81823efdff..97c66cb282 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -27,6 +27,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IGenericServiceFactory _genericServiceFactory; private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; + private readonly IResourceAccessor _resourceAccessor; private readonly TraceLogWriter> _traceWriter; public EntityFrameworkCoreRepository( @@ -36,6 +37,7 @@ public EntityFrameworkCoreRepository( IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) { if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); @@ -46,6 +48,7 @@ public EntityFrameworkCoreRepository( _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + _resourceAccessor = resourceAccessor ?? throw new ArgumentNullException(nameof(constraintProviders)); _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -114,7 +117,8 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationshipAttr in _targetedFields.Relationships) { - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource, out bool relationshipWasAlreadyTracked); + object trackedRelationshipValue = GetTrackedRelationshipValue(out bool relationshipWasAlreadyTracked, relationshipAttr, resource); + bool kk = false; LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); if (relationshipWasAlreadyTracked || relationshipAttr is HasManyThroughAttribute) // We only need to reassign the relationship value to the to-be-added @@ -125,6 +129,7 @@ public virtual async Task CreateAsync(TResource resource) relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); } + _dbContext.Set().Add(resource); await _dbContext.SaveChangesAsync(); @@ -213,7 +218,9 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); foreach (var attribute in _targetedFields.Attributes) + { attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + } foreach (var relationshipAttr in _targetedFields.Relationships) { @@ -222,7 +229,7 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab // trackedRelationshipValue is either equal to updatedPerson.todoItems, // or replaced with the same set (same ids) of todoItems from the EF Core change tracker, // which is the case if they were already tracked - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource, out _); + object trackedRelationshipValue = GetTrackedRelationshipValue(out _, relationshipAttr, requestResource); // loads into the db context any persons currently related // to the todoItems in trackedRelationshipValue LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); @@ -241,51 +248,79 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab /// to the change tracker. It does so by checking if there already are /// instances of the to-be-attached entities in the change tracker. /// - private object GetTrackedRelationshipValue(RelationshipAttribute relationshipAttr, TResource resource, out bool wasAlreadyAttached) + private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, RelationshipAttribute relationship, TResource requestResource) { wasAlreadyAttached = false; - if (relationshipAttr is HasOneAttribute hasOneAttr) + if (relationship is HasOneAttribute hasOneAttr) { - var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(resource); - if (relationshipValue == null) - return null; - return GetTrackedHasOneRelationshipValue(relationshipValue, ref wasAlreadyAttached); + var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(requestResource); + + return GetTrackedRelationshipValue(out wasAlreadyAttached, relationship, relationshipValue?.StringId); + } + else + { + var hasManyAttr = (HasManyAttribute)relationship; + var relationshipValuesCollection = (IEnumerable)hasManyAttr.GetValue(requestResource); + + return GetTrackedRelationshipValue(out wasAlreadyAttached, relationship, relationshipValuesCollection.Select(i => i.StringId).ToArray()); } - - IEnumerable relationshipValues = (IEnumerable)relationshipAttr.GetValue(resource); - if (relationshipValues == null) - return null; - - return GetTrackedManyRelationshipValue(relationshipValues, relationshipAttr, ref wasAlreadyAttached); } - - // helper method used in GetTrackedRelationshipValue. See comments below. - private IEnumerable GetTrackedManyRelationshipValue(IEnumerable relationshipValues, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) + + private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, RelationshipAttribute relationship, params string[] relationshipIds) { - if (relationshipValues == null) return null; - bool newWasAlreadyAttached = false; - var trackedPointerCollection = TypeHelper.CopyToTypedCollection(relationshipValues.Select(pointer => + wasAlreadyAttached = false; + object trackedRelationshipValue = null; + var entityType = relationship.RightType; + + if (relationshipIds != null) { - // convert each element in the value list to relationshipAttr.DependentType. - var tracked = AttachOrGetTracked(pointer); - if (tracked != null) newWasAlreadyAttached = true; - return Convert.ChangeType(tracked ?? pointer, relationshipAttr.RightType); - }), relationshipAttr.Property.PropertyType); - - if (newWasAlreadyAttached) wasAlreadyAttached = true; - return trackedPointerCollection; - } + if (relationship is HasOneAttribute) + { + var id = relationshipIds.Single(); + trackedRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, id, ref wasAlreadyAttached); + } + else + { + var amountOfValues = relationshipIds.Count(); + var collection = new object[amountOfValues]; + + for (int i = 0; i < amountOfValues; i++) + { + var elementOfRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, relationshipIds[i], ref wasAlreadyAttached); + collection[i] = Convert.ChangeType(elementOfRelationshipValue, entityType); + } - // helper method used in GetTrackedRelationshipValue. See comments there. - private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationshipValue, ref bool wasAlreadyAttached) + trackedRelationshipValue = TypeHelper.CopyToTypedCollection(collection, relationship.Property.PropertyType); + } + } + + return trackedRelationshipValue; + } + + private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string id, ref bool wasAlreadyAttached) { - var tracked = AttachOrGetTracked(relationshipValue); - if (tracked != null) wasAlreadyAttached = true; - return tracked ?? relationshipValue; + var trackedEntity = _dbContext.GetTrackedEntity(resourceType, id); + if (trackedEntity == null) + { + // the relationship pointer is new to EF Core, but we are sure + // it exists in the database, so we attach it. In this case, as per + // the json:api spec, we can also safely assume that no fields of + // this resource were updated. Note that if it was already tracked, reattaching it + // will throw an error when calling dbContext.SaveAsync(); + trackedEntity = (IIdentifiable) _resourceFactory.CreateInstance(resourceType); + trackedEntity.StringId = id; + _dbContext.Entry(trackedEntity).State = EntityState.Unchanged; + } + else + { + wasAlreadyAttached = true; + } + + return trackedEntity; } - + /// - public async Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) + public async Task UpdateRelationshipAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); if (parent == null) throw new ArgumentNullException(nameof(parent)); @@ -296,8 +331,26 @@ public async Task UpdateRelationshipAsync(object parent, RelationshipAttribute r ? hasManyThrough.ThroughType : relationship.RightType; - var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); - await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); + LoadCurrentRelationships(parent, relationship); + object trackedRelationshipValue = GetTrackedRelationshipValue(out _, relationship, relationshipIds.ToArray()); + // var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); + // await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); + // if (!(relationship is HasManyThroughAttribute)) + // { + // var accessorResult = await _resourceAccessor.GetResourcesByIdAsync(typeToUpdate, relationshipIds); + // if (relationship is HasOneAttribute) + // { + // trackedRelationshipValue = accessorResult.First(); + // } + // else + // { + // trackedRelationshipValue = TypeHelper.CopyToTypedCollection(accessorResult, relationship.Property.PropertyType); + // } + // } + + LoadInverseRelationships(trackedRelationshipValue, relationship); + + relationship.SetValue(parent, trackedRelationshipValue, _resourceFactory); await _dbContext.SaveChangesAsync(); } @@ -369,34 +422,6 @@ protected void LoadCurrentRelationships(TResource oldResource, RelationshipAttri _dbContext.Entry(oldResource).Collection(hasManyAttribute.Property.Name).Load(); } } - - /// - /// Given a IIdentifiable relationship value, verify if a resource of the underlying - /// type with the same ID is already attached to the dbContext, and if so, return it. - /// If not, attach the relationship value to the dbContext. - /// - /// useful article: https://stackoverflow.com/questions/30987806/dbset-attachentity-vs-dbcontext-entryentity-state-entitystate-modified - /// - private IIdentifiable AttachOrGetTracked(IIdentifiable relationshipValue) - { - var trackedEntity = _dbContext.GetTrackedEntity(relationshipValue); - - if (trackedEntity != null) - { - // there already was an instance of this type and ID tracked - // by EF Core. Reattaching will produce a conflict, so from now on we - // will use the already attached instance instead. This entry might - // contain updated fields as a result of business logic elsewhere in the application - return trackedEntity; - } - - // the relationship pointer is new to EF Core, but we are sure - // it exists in the database, so we attach it. In this case, as per - // the json:api spec, we can also safely assume that no fields of - // this resource were updated. - _dbContext.Entry(relationshipValue).State = EntityState.Unchanged; - return null; - } } /// @@ -412,8 +437,9 @@ public EntityFrameworkCoreRepository( IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs index 14bc56dfca..f391459f04 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs @@ -1,80 +1,22 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Linq; -using System.Reflection; using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Repositories { - /// + /// + /// Retrieves a instance from the D/I container and invokes a callback on it. + /// public interface IResourceAccessor { + /// + /// Gets resources by id. Any id that is not matched is returned as null. + /// + /// The type for which to create a repository. + /// The ids to filter on. Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids); } - - /// - public class ResourceAccessor : IResourceAccessor - { - private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); - private static readonly MethodInfo _accessorMethod; - private readonly IServiceProvider _serviceProvider; - private readonly IResourceContextProvider _provider; - - static ResourceAccessor() - { - _accessorMethod = typeof(ResourceAccessor).GetMethod(nameof(Accessor), BindingFlags.NonPublic | BindingFlags.Static); - } - - private static async Task> Accessor( - IEnumerable ids, - IResourceReadRepository repository, - ResourceContext resourceContext) - where TResource : class, IIdentifiable - { - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - - var queryLayer = new QueryLayer(resourceContext) - { - Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), - ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) - }; - - return await repository.GetAsync(queryLayer); - } - - public ResourceAccessor(IServiceProvider serviceProvider, IResourceContextProvider provider) - { - _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); - _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); - } - - /// - public async Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids) - { - var resourceContext = _provider.GetResourceContext(resourceType); - var repository = GetRepository(resourceType, resourceContext.IdentityType); - - var parameterizedAccessor = _accessorMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); - var resources = (IEnumerable) await parameterizedAccessor.InvokeAsync(null, new[] {ids, repository, resourceContext}); - - var result = ids.Select(id => resources.FirstOrDefault(r => r.StringId == id) ).ToArray(); - - return result; - } - - private object GetRepository(Type resourceType, Type identifierType) - { - var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, identifierType); - var repository = _serviceProvider.GetRequiredService(repositoryType); - - return repository; - } - } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 3e945847fb..12506a534e 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -34,7 +34,7 @@ public interface IResourceWriteRepository /// /// Updates a relationship in the underlying data store. /// - Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); + Task UpdateRelationshipAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); /// /// Deletes a resource from the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs new file mode 100644 index 0000000000..6c2ae35476 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Repositories +{ + /// + public class ResourceAccessor : IResourceAccessor + { + private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); + private static readonly MethodInfo _accessorMethod; + + static ResourceAccessor() + { + _accessorMethod = typeof(ResourceAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Static); + } + + private static async Task> GetById( + IEnumerable ids, + IResourceReadRepository repository, + ResourceContext resourceContext) + where TResource : class, IIdentifiable + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + + var queryLayer = new QueryLayer(resourceContext) + { + Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), + ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) + }; + + return await repository.GetAsync(queryLayer); + } + + private readonly IServiceProvider _serviceProvider; + private readonly IResourceContextProvider _provider; + private readonly Dictionary _parameterizedMethodRepositoryCache = new Dictionary(); + + public ResourceAccessor(IServiceProvider serviceProvider, IResourceContextProvider provider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); + _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); + } + + /// + public async Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids) + { + var resourceContext = _provider.GetResourceContext(resourceType); + var (parameterizedMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); + + var resources = await parameterizedMethod.InvokeAsync(null, ids, repository, resourceContext); + + return (IEnumerable)resources; + } + + private (MethodInfo, object) GetParameterizedMethodAndRepository(Type resourceType, ResourceContext resourceContext) + { + if (!_parameterizedMethodRepositoryCache.TryGetValue(resourceType, out var accessorPair)) + { + var parameterizedMethod = _accessorMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); + var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); + var repository = _serviceProvider.GetRequiredService(repositoryType); + + accessorPair = (parameterizedMethod, repository); + _parameterizedMethodRepositoryCache.Add(resourceType, accessorPair); + } + + return accessorPair; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs index d216db5818..e56ba3ee8a 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs @@ -99,6 +99,7 @@ private IReadOnlyCollection GetAttributesToSerialize(IIdentifiabl /// private IReadOnlyCollection GetRelationshipsToSerialize(IIdentifiable resource) { + return RelationshipsToSerialize; var currentResourceType = resource.GetType(); // only allow relationship attributes to be serialized if they were set using // diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 29637bdb91..bbd4f3039f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -66,8 +67,6 @@ public JsonApiResourceService( _hookExecutor = hookExecutor; } - #region Primary resource pipelines - /// // triggered by POST /articles public virtual async Task CreateAsync(TResource resource) @@ -295,11 +294,7 @@ public virtual async Task DeleteAsync(TId id) AssertPrimaryResourceExists(null); } } - - #endregion - - #region Relationship link pipelines - + /// // triggered by POST /articles/{id}/relationships/{relationshipName} public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) @@ -388,11 +383,15 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); primaryLayer.Projection = null; + primaryLayer.Include = null; + var primaryResources = await _repository.GetAsync(primaryLayer); var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - + + await AssertRelationshipValuesExistAsync((_request.Relationship, relationships)); + if (_hookExecutor != null) { primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); @@ -440,14 +439,11 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnum AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(relationshipName); - var relationship = GetRelationshipAttribute(relationshipName); - await AssertRelationshipValuesExistAsync((relationship, relationships)); + await AssertRelationshipValuesExistAsync((_request.Relationship, relationships)); throw new NotImplementedException(); } - #endregion - private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) { assignments = _targetedFields.Relationships @@ -508,14 +504,14 @@ private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttrib } else { - identifiers = ((IEnumerable) relationshipValue).Select(i => i.StringId); + identifiers = ((IEnumerable) relationshipValue).Select(i => i.StringId).ToArray(); } var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); var missing = identifiers.Where(id => resources.All(r => r?.StringId != id)).ToArray(); if (missing.Any()) { - nonExistingResources.Add(_provider.GetResourceContext(relationship.RightType).PublicName, missing.ToList()); + nonExistingResources.Add(_provider.GetResourceContext(relationship.RightType).PublicName, missing.ToArray()); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index ae15051ab4..e8d0b27dcc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -347,7 +347,8 @@ public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() newPerson.Passport = passport; // Act - var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); + var requestBody = serializer.Serialize(newPerson); + var (body, response) = await Post("/api/v1/people", requestBody); // Assert AssertEqualStatusCode(HttpStatusCode.Created, response); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index ad62bb6b6e..219dc1b5d9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -320,7 +320,8 @@ public async Task Can_Patch_Resource() var client = server.CreateClient(); var serializer = TestFixture.GetSerializer(server.Host.Services, p => new { p.Description, p.Ordinal }); - var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{todoItem.Id}", serializer.Serialize(newTodoItem)); + var requestBody = serializer.Serialize(newTodoItem); + var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{todoItem.Id}",requestBody); // Act var response = await client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 2fcda0f0c1..3afef4741d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -428,7 +428,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overlap() { // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; + var todoCollection = new TodoItemCollection { TodoItems = new HashSet() }; var person = _personFaker.Generate(); var todoItem1 = _todoItemFaker.Generate(); var todoItem2 = _todoItemFaker.Generate(); @@ -751,8 +751,8 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await client.SendAsync(request); - + var response = await client.SendAsync(request); ; + // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); _context = _fixture.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index 557834c518..10fa4013a3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -23,10 +23,11 @@ public ResultCapturingRepository( IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory, ResourceCaptureStore captureStore) : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + constraintProviders, resourceAccessor, loggerFactory) { _captureStore = captureStore; } From 57b1021ea6fd8ead7bac2cffa4d475f068cd9d33 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 5 Oct 2020 20:10:59 +0200 Subject: [PATCH 015/240] fix: tests repo bugfixes --- .../Repositories/DbContextARepository.cs | 9 +-- .../Repositories/DbContextBRepository.cs | 9 +-- .../Services/WorkItemService.cs | 10 +++ .../Controllers/ReportsController.cs | 44 +++++------ .../EntityFrameworkCoreRepository.cs | 75 ++++++------------- .../Services/JsonApiResourceService.cs | 18 +++-- src/JsonApiDotNetCore/TypeHelper.cs | 11 +++ .../ServiceDiscoveryFacadeTests.cs | 9 ++- .../EntityFrameworkCoreRepositoryTests.cs | 4 +- .../ResultCapturingRepository.cs | 3 +- .../BaseJsonApiController_Tests.cs | 15 ++-- .../IServiceCollectionExtensionsTests.cs | 4 + .../ResourceHooks/ResourceHooksTestsSetup.cs | 6 +- .../Services/DefaultResourceService_Tests.cs | 6 +- 14 files changed, 115 insertions(+), 108 deletions(-) diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 751c02703a..fa6bb24700 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -12,11 +12,10 @@ public class DbContextARepository : EntityFrameworkCoreRepository { public DbContextARepository(ITargetedFields targetedFields, DbContextResolver contextResolver, - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, + constraintProviders, resourceAccessor, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index c0761187b1..6ceeaf6ffe 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -12,11 +12,10 @@ public class DbContextBRepository : EntityFrameworkCoreRepository { public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver contextResolver, - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, + constraintProviders, resourceAccessor, loggerFactory) { } } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 6598936b6f..d1c11944ef 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Dapper; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Configuration; using NoEntityFrameworkExample.Models; @@ -79,5 +80,14 @@ private async Task> QueryAsync(Func new NpgsqlConnection(_connectionString); + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationships) + { + throw new NotImplementedException(); + } + + public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationships) + { + throw new NotImplementedException(); + } } } diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index c80aba4680..54fbc24bff 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -1,24 +1,24 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using ReportsExample.Models; - -namespace ReportsExample.Controllers -{ - [Route("api/[controller]")] - public class ReportsController : BaseJsonApiController - { +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using ReportsExample.Models; + +namespace ReportsExample.Controllers +{ + [Route("api/[controller]")] + public class ReportsController : BaseJsonApiController + { public ReportsController( IJsonApiOptions options, - ILoggerFactory loggerFactory, - IGetAllService getAll) - : base(options, loggerFactory, getAll) - { } - - [HttpGet] - public override async Task GetAsync() => await base.GetAsync(); - } -} + ILoggerFactory loggerFactory, + IGetAllService getAll) + : base(options, loggerFactory, getAll: getAll) + { } + + [HttpGet] + public override async Task GetAsync() => await base.GetAsync(); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 97c66cb282..80c39b52fc 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -24,7 +24,6 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; - private readonly IGenericServiceFactory _genericServiceFactory; private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; private readonly IResourceAccessor _resourceAccessor; @@ -34,7 +33,6 @@ public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, IResourceAccessor resourceAccessor, @@ -45,7 +43,6 @@ public EntityFrameworkCoreRepository( _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); - _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceAccessor = resourceAccessor ?? throw new ArgumentNullException(nameof(constraintProviders)); @@ -114,22 +111,14 @@ public virtual async Task CreateAsync(TResource resource) { _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - + foreach (var relationshipAttr in _targetedFields.Relationships) { - object trackedRelationshipValue = GetTrackedRelationshipValue(out bool relationshipWasAlreadyTracked, relationshipAttr, resource); - bool kk = false; + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource); LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - if (relationshipWasAlreadyTracked || relationshipAttr is HasManyThroughAttribute) - // We only need to reassign the relationship value to the to-be-added - // resource when we're using a different instance of the relationship (because this different one - // was already tracked) than the one assigned to the to-be-created resource. - // Alternatively, even if we don't have to reassign anything because of already tracked - // entities, we still need to assign the "through" entities in the case of many-to-many. - relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); + relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); } - - + _dbContext.Set().Add(resource); await _dbContext.SaveChangesAsync(); @@ -229,7 +218,7 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab // trackedRelationshipValue is either equal to updatedPerson.todoItems, // or replaced with the same set (same ids) of todoItems from the EF Core change tracker, // which is the case if they were already tracked - object trackedRelationshipValue = GetTrackedRelationshipValue(out _, relationshipAttr, requestResource); + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource); // loads into the db context any persons currently related // to the todoItems in trackedRelationshipValue LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); @@ -248,27 +237,30 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab /// to the change tracker. It does so by checking if there already are /// instances of the to-be-attached entities in the change tracker. /// - private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, RelationshipAttribute relationship, TResource requestResource) + private object GetTrackedRelationshipValue(RelationshipAttribute relationship, TResource requestResource) { - wasAlreadyAttached = false; if (relationship is HasOneAttribute hasOneAttr) { var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(requestResource); + + if (relationshipValue == null) + { + return null; + } - return GetTrackedRelationshipValue(out wasAlreadyAttached, relationship, relationshipValue?.StringId); + return GetTrackedRelationshipValue(relationship, relationshipValue.StringId); } else { var hasManyAttr = (HasManyAttribute)relationship; var relationshipValuesCollection = (IEnumerable)hasManyAttr.GetValue(requestResource); - return GetTrackedRelationshipValue(out wasAlreadyAttached, relationship, relationshipValuesCollection.Select(i => i.StringId).ToArray()); + return GetTrackedRelationshipValue(relationship, relationshipValuesCollection.Select(i => i.StringId).ToArray()); } } - private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, RelationshipAttribute relationship, params string[] relationshipIds) + private object GetTrackedRelationshipValue(RelationshipAttribute relationship, params string[] relationshipIds) { - wasAlreadyAttached = false; object trackedRelationshipValue = null; var entityType = relationship.RightType; @@ -277,7 +269,7 @@ private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, Relation if (relationship is HasOneAttribute) { var id = relationshipIds.Single(); - trackedRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, id, ref wasAlreadyAttached); + trackedRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, id); } else { @@ -286,7 +278,7 @@ private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, Relation for (int i = 0; i < amountOfValues; i++) { - var elementOfRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, relationshipIds[i], ref wasAlreadyAttached); + var elementOfRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, relationshipIds[i]); collection[i] = Convert.ChangeType(elementOfRelationshipValue, entityType); } @@ -297,7 +289,7 @@ private object GetTrackedRelationshipValue(out bool wasAlreadyAttached, Relation return trackedRelationshipValue; } - private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string id, ref bool wasAlreadyAttached) + private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string id) { var trackedEntity = _dbContext.GetTrackedEntity(resourceType, id); if (trackedEntity == null) @@ -311,11 +303,7 @@ private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string trackedEntity.StringId = id; _dbContext.Entry(trackedEntity).State = EntityState.Unchanged; } - else - { - wasAlreadyAttached = true; - } - + return trackedEntity; } @@ -326,30 +314,10 @@ public async Task UpdateRelationshipAsync(TResource parent, RelationshipAttribut if (parent == null) throw new ArgumentNullException(nameof(parent)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); - - var typeToUpdate = relationship is HasManyThroughAttribute hasManyThrough - ? hasManyThrough.ThroughType - : relationship.RightType; - + LoadCurrentRelationships(parent, relationship); - object trackedRelationshipValue = GetTrackedRelationshipValue(out _, relationship, relationshipIds.ToArray()); - // var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); - // await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); - // if (!(relationship is HasManyThroughAttribute)) - // { - // var accessorResult = await _resourceAccessor.GetResourcesByIdAsync(typeToUpdate, relationshipIds); - // if (relationship is HasOneAttribute) - // { - // trackedRelationshipValue = accessorResult.First(); - // } - // else - // { - // trackedRelationshipValue = TypeHelper.CopyToTypedCollection(accessorResult, relationship.Property.PropertyType); - // } - // } - + object trackedRelationshipValue = GetTrackedRelationshipValue(relationship, relationshipIds.ToArray()); LoadInverseRelationships(trackedRelationshipValue, relationship); - relationship.SetValue(parent, trackedRelationshipValue, _resourceFactory); await _dbContext.SaveChangesAsync(); @@ -434,12 +402,11 @@ public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index bbd4f3039f..fcf017d021 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -79,10 +79,16 @@ public virtual async Task CreateAsync(TResource resource) resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); } + if (HasNonNullRelationshipAssignments(resource, out var assignments)) + { + await AssertRelationshipValuesExistAsync(assignments); + } + await _repository.CreateAsync(resource); resource = await GetPrimaryResourceById(resource.Id, true); - + + if (_hookExecutor != null) { _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); @@ -401,8 +407,8 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, if (relationships != null) { relationshipIds = _request.Relationship is HasOneAttribute - ? new[] {((IIdentifiable) relationships).StringId} - : ((IEnumerable) relationships).Select(e => e.StringId).ToArray(); + ? new[] {TypeHelper.GetIdValue((IIdentifiable) relationships)} + : ((IEnumerable) relationships).Select(TypeHelper.GetIdValue).ToArray(); } await _repository.UpdateRelationshipAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); @@ -500,15 +506,15 @@ private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttrib IEnumerable identifiers; if (relationshipValue is IIdentifiable identifiable) { - identifiers = new [] { identifiable.StringId }; + identifiers = new [] { TypeHelper.GetIdValue(identifiable) }; } else { - identifiers = ((IEnumerable) relationshipValue).Select(i => i.StringId).ToArray(); + identifiers = ((IEnumerable) relationshipValue).Select(TypeHelper.GetIdValue).ToArray(); } var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); - var missing = identifiers.Where(id => resources.All(r => r?.StringId != id)).ToArray(); + var missing = identifiers.Where(id => resources.All(r => TypeHelper.GetIdValue(r) != id)).ToArray(); if (missing.Any()) { nonExistingResources.Add(_provider.GetResourceContext(relationship.RightType).PublicName, missing.ToArray()); diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 348612bd5e..4039cf02ab 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -246,6 +246,17 @@ public static Type GetIdType(Type resourceType) return property.PropertyType; } + /// + /// Gets the value of the id of an identifiable. This is recommended over using `StringId` because this might + /// fail when the model has obfuscated IDs. + /// + public static string GetIdValue(IIdentifiable identifiable) + { + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + + return identifiable.GetType().GetProperty(nameof(Identifiable.Id)).GetValue(identifiable)?.ToString(); + } + public static object CreateInstance(Type type) { if (type == null) diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 5a7a67c000..3f5757a088 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -153,9 +153,12 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, + IResourceAccessor resourceAccessor, + ITargetedFields targetedFields, + IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + resourceChangeTracker, resourceFactory, resourceAccessor, targetedFields, provider, hookExecutor) { } } @@ -166,11 +169,11 @@ public TestModelRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) { } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 09174695cd..1c49416027 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -88,8 +88,8 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri contextResolverMock.Setup(m => m.GetContext()).Returns(context); var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); var targetedFields = new Mock(); - var serviceFactory = new Mock().Object; - var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); + var resourceAccessor = new Mock().Object; + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, resourceFactory, new List(), resourceAccessor, NullLoggerFactory.Instance); return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index 10fa4013a3..5277eab0c0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -20,13 +20,12 @@ public ResultCapturingRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory, ResourceCaptureStore captureStore) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) { _captureStore = captureStore; diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 842e1558da..fe2311c6a2 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -35,17 +35,20 @@ public ResourceController( public ResourceController( IJsonApiOptions options, ILoggerFactory loggerFactory, + ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, - ICreateService create = null, IUpdateService update = null, + IDeleteService delete = null, + IAddRelationshipService addRelationship = null, + IGetRelationshipService getRelationship = null, ISetRelationshipService setRelationship = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, - update, setRelationship, delete) - { } + IDeleteRelationshipService deleteRelationship = null) + : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, addRelationship, + getRelationship, setRelationship, deleteRelationship) + { + } } [Fact] diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index a9131d2f54..fa16676ea6 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -168,6 +168,8 @@ private class IntResourceService : IResourceService public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object relationships) => throw new NotImplementedException(); + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -180,6 +182,8 @@ private class GuidResourceService : IResourceService public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object relationships) => throw new NotImplementedException(); + public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); } diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 138c1f61df..587500efd8 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Bogus; +using Castle.Core.Resource; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Hooks.Internal; @@ -19,6 +20,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using IResourceFactory = JsonApiDotNetCore.Resources.IResourceFactory; using Person = JsonApiDotNetCoreExample.Models.Person; namespace UnitTests.ResourceHooks @@ -370,9 +372,9 @@ private IResourceReadRepository CreateTestRepository(AppDbC var serviceProvider = ((IInfrastructure) dbContext).Instance; var resourceFactory = new ResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); - var serviceFactory = new Mock().Object; + var resourceAccessor = new Mock().Object; var targetedFields = new TargetedFields(); - return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); + return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, resourceFactory, new List(), resourceAccessor, NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index c9b7e2b28b..c307c05f8f 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -76,6 +76,10 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); + var resourceAccessor = new Mock().Object; + var targetedFields = new Mock().Object; + var resourceContextProvider = new Mock().Object; + var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); var request = new JsonApiRequest { @@ -86,7 +90,7 @@ private JsonApiResourceService GetService() }; return new JsonApiResourceService(_repositoryMock.Object, composer, paginationContext, options, - NullLoggerFactory.Instance, request, changeTracker, resourceFactory, null); + NullLoggerFactory.Instance, request, changeTracker, resourceFactory, resourceAccessor, targetedFields, resourceContextProvider, null); } } } From 9b422c3963f93f8426cf98a490bed6d20b2fc60f Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 5 Oct 2020 22:23:33 +0200 Subject: [PATCH 016/240] feat: post and delete to-many --- .../Services/WorkItemService.cs | 6 +- .../Queries/IQueryLayerComposer.cs | 2 +- .../Queries/Internal/QueryLayerComposer.cs | 17 ++- .../EntityFrameworkCoreRepository.cs | 66 +++++---- .../Repositories/IResourceWriteRepository.cs | 2 +- .../Services/IAddRelationshipService.cs | 2 +- .../Services/IDeleteRelationshipService.cs | 2 +- .../Services/ISetRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 127 +++++++++--------- .../Spec/UpdatingRelationshipsTests.cs | 8 +- .../IServiceCollectionExtensionsTests.cs | 12 +- 11 files changed, 129 insertions(+), 117 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index d1c11944ef..0dc7047b19 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -67,7 +67,7 @@ public Task UpdateAsync(int id, WorkItem requestResource) throw new NotImplementedException(); } - public Task SetRelationshipAsync(int id, string relationshipName, object relationships) + public Task SetRelationshipAsync(int id, string relationshipName, object relationshipValues) { throw new NotImplementedException(); } @@ -80,12 +80,12 @@ private async Task> QueryAsync(Func new NpgsqlConnection(_connectionString); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationships) + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relatinshipValues) { throw new NotImplementedException(); } - public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationships) + public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index dd2657e6a2..72ed02ef95 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -24,7 +24,7 @@ public interface IQueryLayerComposer /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. /// QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, - TId primaryId, RelationshipAttribute secondaryRelationship); + TId primaryId, RelationshipAttribute secondaryRelationship = null); /// /// Gets the secondary projection for a relationship endpoint. diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 58d723f13f..51127f7b71 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -177,7 +177,7 @@ private static IReadOnlyCollection ApplyIncludeElement } /// - public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship) + public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship = null) { var innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; @@ -186,17 +186,26 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, var sparseFieldSet = new SparseFieldSetExpression(new[] { primaryIdAttribute }); var primaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, primaryResourceContext) ?? new Dictionary(); - primaryProjection[secondaryRelationship] = secondaryLayer; + if (secondaryRelationship != null) + { + primaryProjection[secondaryRelationship] = secondaryLayer; + } primaryProjection[primaryIdAttribute] = null; var primaryFilter = GetFilter(Array.Empty(), primaryResourceContext); - return new QueryLayer(primaryResourceContext) + var queryLayer = new QueryLayer(primaryResourceContext) { - Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), Filter = IncludeFilterById(primaryId, primaryResourceContext, primaryFilter), Projection = primaryProjection }; + + if (secondaryRelationship != null) + { + queryLayer.Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship); + } + + return queryLayer; } private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude, RelationshipAttribute secondaryRelationship) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 80c39b52fc..77c58d3664 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -114,7 +114,8 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationshipAttr in _targetedFields.Relationships) { - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource); + var relationshipIds = GetRelationshipIds(relationshipAttr, resource); + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, relationshipIds.ToArray() ); LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); } @@ -213,15 +214,19 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab foreach (var relationshipAttr in _targetedFields.Relationships) { - // loads databasePerson.todoItems + // loads databasePerson.todoItems. Required for complete replacements LoadCurrentRelationships(databaseResource, relationshipAttr); + // trackedRelationshipValue is either equal to updatedPerson.todoItems, // or replaced with the same set (same ids) of todoItems from the EF Core change tracker, // which is the case if they were already tracked - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource); + var relationshipIds = GetRelationshipIds(relationshipAttr, requestResource); + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, relationshipIds); + // loads into the db context any persons currently related // to the todoItems in trackedRelationshipValue LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); + // assigns the updated relationship to the database resource //AssignRelationshipValue(databaseResource, trackedRelationshipValue, relationshipAttr); relationshipAttr.SetValue(databaseResource, trackedRelationshipValue, _resourceFactory); @@ -229,7 +234,7 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab await _dbContext.SaveChangesAsync(); } - + /// /// Responsible for getting the relationship value for a given relationship /// attribute of a given resource. It ensures that the relationship value @@ -237,55 +242,58 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab /// to the change tracker. It does so by checking if there already are /// instances of the to-be-attached entities in the change tracker. /// - private object GetTrackedRelationshipValue(RelationshipAttribute relationship, TResource requestResource) + private string[] GetRelationshipIds(RelationshipAttribute relationship, TResource requestResource) { if (relationship is HasOneAttribute hasOneAttr) { - var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(requestResource); + var relationshipValue = (IIdentifiable) hasOneAttr.GetValue(requestResource); if (relationshipValue == null) { - return null; + return new string[0]; } - - return GetTrackedRelationshipValue(relationship, relationshipValue.StringId); + + return new[] { relationshipValue.StringId }; } else { var hasManyAttr = (HasManyAttribute)relationship; var relationshipValuesCollection = (IEnumerable)hasManyAttr.GetValue(requestResource); - - return GetTrackedRelationshipValue(relationship, relationshipValuesCollection.Select(i => i.StringId).ToArray()); + + return relationshipValuesCollection.Select(i => i.StringId).ToArray(); } } private object GetTrackedRelationshipValue(RelationshipAttribute relationship, params string[] relationshipIds) { - object trackedRelationshipValue = null; + object trackedRelationshipValue; var entityType = relationship.RightType; - if (relationshipIds != null) + if (relationship is HasOneAttribute) { - if (relationship is HasOneAttribute) + if (!relationshipIds.Any()) { - var id = relationshipIds.Single(); - trackedRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, id); + return null; } - else - { - var amountOfValues = relationshipIds.Count(); - var collection = new object[amountOfValues]; - - for (int i = 0; i < amountOfValues; i++) - { - var elementOfRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, relationshipIds[i]); - collection[i] = Convert.ChangeType(elementOfRelationshipValue, entityType); - } + + var id = relationshipIds.Single(); + trackedRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, id); + } + else + { + var amountOfValues = relationshipIds.Count(); + var collection = new object[amountOfValues]; - trackedRelationshipValue = TypeHelper.CopyToTypedCollection(collection, relationship.Property.PropertyType); + for (int i = 0; i < amountOfValues; i++) + { + var elementOfRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, relationshipIds[i]); + collection[i] = Convert.ChangeType(elementOfRelationshipValue, entityType); } + + trackedRelationshipValue = TypeHelper.CopyToTypedCollection(collection, relationship.Property.PropertyType); } + return trackedRelationshipValue; } @@ -308,13 +316,13 @@ private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string } /// - public async Task UpdateRelationshipAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) + public async Task SetRelationshipsAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); if (parent == null) throw new ArgumentNullException(nameof(parent)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); - + LoadCurrentRelationships(parent, relationship); object trackedRelationshipValue = GetTrackedRelationshipValue(relationship, relationshipIds.ToArray()); LoadInverseRelationships(trackedRelationshipValue, relationship); diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 12506a534e..3d6d16583d 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -34,7 +34,7 @@ public interface IResourceWriteRepository /// /// Updates a relationship in the underlying data store. /// - Task UpdateRelationshipAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); + Task SetRelationshipsAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); /// /// Deletes a resource from the underlying data store. diff --git a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs index 529adddd9b..52834b2a23 100644 --- a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs @@ -15,6 +15,6 @@ public interface IAddRelationshipService where TResource : cl /// /// Handles a json:api request to add resources to a to-many relationship. /// - Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); + Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relatinshipValues); } } diff --git a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs index e5c1efad83..c3edc64167 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs @@ -14,6 +14,6 @@ public interface IDeleteRelationshipService where TResource : /// /// Handles a json:api request to remove resources from a to-many relationship. /// - Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships); + Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues); } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 3d20be259c..8b501365b7 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -14,6 +14,6 @@ public interface ISetRelationshipService where TResource : cl /// /// Handles a json:api request to update an existing relationship. /// - Task SetRelationshipAsync(TId id, string relationshipName, object relationships); + Task SetRelationshipAsync(TId id, string relationshipName, object relationshipValues); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index fcf017d021..d91071ce59 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Http; @@ -15,6 +16,7 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore.Internal; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Services @@ -303,43 +305,33 @@ public virtual async Task DeleteAsync(TId id) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relatinshipValues) { - /* - * APPROACH: - * - get all relationships through repository - * - construct accurate relationshipsId list - * - use repo.UpdateAsync method. POST vs PATCH part of the spec will be abstracted away from repo this way - * - EF Core: - * one-to-many: will probably iterate through list and set FK to primaryResource.id. C ~ relationshipsId.Count - * X optimal performance: we could do this without getting any data. Now it does. - * many-to-many: add new join table records. What if they already exist? - * X here we will always need to get the join table records first to make sure we are not inserting one that already exists, so no performance loss - * - * Conclusion - * => for creation we only need to fetch data if relationships is many-to-many. so for many-to-many it doesnt matter if we create reuse repo.UpdateAsync, - * or not. For to-many, we never need to fetch data, so we wont leverage this performance opportunity if we re-use repo.UpdateAsync - * - * - * Rectification: - * before adding a relationship we need to see if it acutally exists. If not we must return 404. - * - * - * So new conclusion: we always need to fetch the to be added or deleted relationship - * we dont have to fetch the current state of the relationship. - * unless many-to-many: I think query wil fail if we create double entry in jointable when the pre-existing entry is not being tracked in dbcontext. - */ - - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationships = relatinshipValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(relationshipName); - var relationship = GetRelationshipAttribute(relationshipName); - await AssertRelationshipValuesExistAsync((relationship, relationships)); + await AssertRelationshipValuesExistAsync((_request.Relationship, relatinshipValues)); + + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + queryLayer.Include = IncludeRelationship(_request.Relationship); + queryLayer.Filter = IncludeFilterById(id, null); - throw new NotImplementedException(); + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + var relationshipValueCollection = ((IEnumerable) _request.Relationship.GetValue(primaryResource)).Select(i => i.StringId).ToList(); + foreach (var entry in relatinshipValues) + { + if (!relationshipValueCollection.Contains(entry.StringId)) + { + relationshipValueCollection.Add(entry.StringId); + } + } + + await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipValueCollection); } /// @@ -375,9 +367,9 @@ public virtual async Task GetRelationshipAsync(TId id, string relatio /// // triggered by PATCH /articles/{id}/relationships/{relationshipName} - public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationships) + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationshipValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -386,17 +378,18 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); secondaryLayer.Include = null; - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id); primaryLayer.Projection = null; - - primaryLayer.Include = null; var primaryResources = await _repository.GetAsync(primaryLayer); var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - - await AssertRelationshipValuesExistAsync((_request.Relationship, relationships)); + + if (relationshipValues != null) + { + await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipValues)); + } if (_hookExecutor != null) { @@ -404,14 +397,14 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } string[] relationshipIds = null; - if (relationships != null) + if (relationshipValues != null) { relationshipIds = _request.Relationship is HasOneAttribute - ? new[] {TypeHelper.GetIdValue((IIdentifiable) relationships)} - : ((IEnumerable) relationships).Select(TypeHelper.GetIdValue).ToArray(); + ? new[] {TypeHelper.GetIdValue((IIdentifiable) relationshipValues)} + : ((IEnumerable) relationshipValues).Select(TypeHelper.GetIdValue).ToArray(); } - await _repository.UpdateRelationshipAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); + await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); if (_hookExecutor != null && primaryResource != null) { @@ -421,33 +414,33 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationships) + public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues) { - /* - * APPROACH ONE: - * - get all relationships through repository - * - construct accurate relationshipsId list - * - use repo.UpdateAsync method. POST vs PATCH part of the spec will be abstracted away from repo this way - * - EF Core: - * one-to-many: will probably iterate through list and set FK to primaryResource.id. C ~ amount of new ids - * X optimal performance: we could do this without getting any data. Now it does. - * many-to-many: iterates over list and creates DELETE query per removed id. C ~ amount of new ids - * X delete join table records. No need to fetch them first. Now it does. - * - * Conclusion - * => for delete we wont ever need to fetch data first. If we reuse repo.UpdateAsync, - * we wont leverage this performance opportunity - */ - - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(relationshipName); + await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipValues)); - await AssertRelationshipValuesExistAsync((_request.Relationship, relationships)); + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + queryLayer.Include = IncludeRelationship(_request.Relationship); + queryLayer.Filter = IncludeFilterById(id, null); + + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); - throw new NotImplementedException(); + var relationshipValueCollection = ((IEnumerable) _request.Relationship.GetValue(primaryResource)).Select(TypeHelper.GetIdValue).ToList(); + foreach (var entry in relationshipValues) + { + if (relationshipValueCollection.Contains(entry.StringId)) + { + relationshipValueCollection.Remove(entry.StringId); + } + } + + await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipValueCollection); + } private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) @@ -466,12 +459,7 @@ private bool HasNonNullRelationshipAssignments(TResource requestResource, out (R return assignments.Any(); } - - private RelationshipAttribute GetRelationshipAttribute(string relationshipName) - { - return _provider.GetResourceContext().Relationships.Single(attr => attr.PublicName == relationshipName); - } - + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -527,6 +515,11 @@ private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttrib } } + private IncludeExpression IncludeRelationship(RelationshipAttribute relationship) + { + return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); + } + private List AsList(TResource resource) { return new List { resource }; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 3afef4741d..802345aa5d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -833,7 +834,8 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo // Act var response = await client.SendAsync(request); - + var responseBody = await response.Content.ReadAsStringAsync(); + // Assert var todoItemResult = _context.TodoItems .AsNoTracking() @@ -894,7 +896,7 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() .Single(p => p.Id == person.Id).TodoItems; Assert.Equal(4, assertTodoItems.Count); - Assert.Equal(todoItem.Id, assertTodoItems.ElementAt(3).Id); + Assert.True(assertTodoItems.Any(ati => ati.Id == todoItem.Id)); } [Fact] @@ -941,7 +943,7 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); _context = _fixture.GetRequiredService(); - var assertTodoItems = _context.People.Include(p => p.TodoItems) + var assertTodoItems = _context.People.AsNoTracking().Include(p => p.TodoItems) .Single(p => p.Id == person.Id).TodoItems; Assert.Equal(2, assertTodoItems.Count); diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index fa16676ea6..8abd09d00e 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -167,9 +167,9 @@ private class IntResourceService : IResourceService public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(int id, string relationshipName, object relationships) => throw new NotImplementedException(); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object relationshipValues) => throw new NotImplementedException(); + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relatinshipValues) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -181,9 +181,9 @@ private class GuidResourceService : IResourceService public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(Guid id, string relationshipName, object relationships) => throw new NotImplementedException(); - public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationships) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipValues) => throw new NotImplementedException(); + public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relatinshipValues) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } From 0cf26e3cda9e8ca8375090bf31d7c0a7bf944a42 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 07:18:03 +0200 Subject: [PATCH 017/240] chore: restore controller order --- .../Services/WorkItemService.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 181 +++++++++--------- .../Controllers/JsonApiController.cs | 68 +++---- .../Services/IAddRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 21 +- .../IServiceCollectionExtensionsTests.cs | 4 +- 6 files changed, 132 insertions(+), 146 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 0dc7047b19..617c1c66b3 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -80,7 +80,7 @@ private async Task> QueryAsync(Func new NpgsqlConnection(_connectionString); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relatinshipValues) + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 4e14a08a1d..920f18f7c6 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -25,10 +25,10 @@ public abstract class BaseJsonApiController : CoreJsonApiControl private readonly IGetSecondaryService _getSecondary; private readonly IGetRelationshipService _getRelationship; private readonly ICreateService _create; - private readonly IUpdateService _update; - private readonly IDeleteService _delete; private readonly IAddRelationshipService _addRelationship; + private readonly IUpdateService _update; private readonly ISetRelationshipService _setRelationship; + private readonly IDeleteService _delete; private readonly IDeleteRelationshipService _deleteRelationship; private readonly TraceLogWriter> _traceWriter; @@ -40,7 +40,7 @@ protected BaseJsonApiController( ILoggerFactory loggerFactory, IResourceService resourceService) : this(options, loggerFactory, resourceService, resourceService, resourceService, resourceService, - resourceService, resourceService, resourceService, resourceService, resourceService, resourceService) + resourceService, resourceService, resourceService, resourceService) { } /// @@ -51,8 +51,8 @@ protected BaseJsonApiController( ILoggerFactory loggerFactory, IResourceQueryService queryService = null, IResourceCommandService commandService = null) - : this(options, loggerFactory, commandService, queryService, queryService, queryService, commandService, - commandService, commandService, queryService, commandService, commandService) + : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, + commandService, commandService, commandService, commandService, commandService) { } /// @@ -61,63 +61,33 @@ protected BaseJsonApiController( protected BaseJsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IUpdateService update = null, - IDeleteService delete = null, - IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, + ICreateService create = null, + IAddRelationshipService addRelationship = null, + IUpdateService update = null, ISetRelationshipService setRelationship = null, - IDeleteRelationshipService deleteRelationship = null - ) + IDeleteService delete = null, + IDeleteRelationshipService deleteRelationship = null) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _options = options ?? throw new ArgumentNullException(nameof(options)); _traceWriter = new TraceLogWriter>(loggerFactory); - _create = create; _getAll = getAll; _getById = getById; _getSecondary = getSecondary; - _update = update; - _delete = delete; - _addRelationship = addRelationship; _getRelationship = getRelationship; + _create = create; + _addRelationship = addRelationship; + _update = update; _setRelationship = setRelationship; + _delete = delete; _deleteRelationship = deleteRelationship; } - #region Primary Resource Endpoints - - /// - /// Creates a new resource. - /// - public virtual async Task PostAsync([FromBody] TResource resource) - { - _traceWriter.LogMethodStart(new {resource}); - - if (_create == null) - throw new RequestMethodNotAllowedException(HttpMethod.Post); - - if (resource == null) - throw new InvalidRequestBodyException(null, null, null); - - if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) - throw new ResourceIdInPostRequestNotAllowedException(); - - if (_options.ValidateModelState && !ModelState.IsValid) - { - var namingStrategy = _options.SerializerContractResolver.NamingStrategy; - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); - } - - resource = await _create.CreateAsync(resource); - - return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); - } - /// /// Gets a collection of top-level (non-nested) resources. /// Example: GET /articles HTTP/1.1 @@ -143,7 +113,22 @@ public virtual async Task GetAsync(TId id) var resource = await _getById.GetAsync(id); return Ok(resource); } - + + /// + /// Gets a single resource relationship. + /// Example: GET /articles/1/relationships/author HTTP/1.1 + /// + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + { + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + + return Ok(relationship); + } + /// /// Gets a single resource or multiple resources at a nested endpoint. /// Examples: @@ -159,59 +144,34 @@ public virtual async Task GetSecondaryAsync(TId id, string relati var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } - + /// - /// Updates an existing resource. May contain a partial set of attributes. + /// Creates a new resource. /// - public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) + public virtual async Task PostAsync([FromBody] TResource resource) { - _traceWriter.LogMethodStart(new {id, resource}); + _traceWriter.LogMethodStart(new {resource}); + + if (_create == null) + throw new RequestMethodNotAllowedException(HttpMethod.Post); - if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); if (resource == null) throw new InvalidRequestBodyException(null, null, null); + if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) + throw new ResourceIdInPostRequestNotAllowedException(); + if (_options.ValidateModelState && !ModelState.IsValid) { var namingStrategy = _options.SerializerContractResolver.NamingStrategy; throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); } - var updated = await _update.UpdateAsync(id, resource); - return updated == null ? Ok(null) : Ok(updated); - } - - /// - /// Deletes a resource. - /// - public virtual async Task DeleteAsync(TId id) - { - _traceWriter.LogMethodStart(new {id}); - - if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _delete.DeleteAsync(id); + resource = await _create.CreateAsync(resource); - return NoContent(); + return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); } - #endregion - - #region Relationship Link Endpoints - - /// - /// Gets a single resource relationship. - /// Example: GET /articles/1/relationships/author HTTP/1.1 - /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) - { - _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - - if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); - return Ok(relationship); - } - /// /// Adds resources to a to-many relationship. /// @@ -224,9 +184,30 @@ public virtual async Task PostRelationshipAsync(TId id, string re await _addRelationship.AddRelationshipAsync(id, relationshipName, relationships); return Ok(); } - + + /// + /// Updates an existing resource. May contain a partial set of attributes. + /// + public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) + { + _traceWriter.LogMethodStart(new {id, resource}); + + if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); + if (resource == null) + throw new InvalidRequestBodyException(null, null, null); + + if (_options.ValidateModelState && !ModelState.IsValid) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); + } + + var updated = await _update.UpdateAsync(id, resource); + return updated == null ? Ok(null) : Ok(updated); + } + /// - /// Sets the resource(s) of a relationship. + /// Updates a relationship. /// public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) { @@ -237,7 +218,20 @@ public virtual async Task PatchRelationshipAsync(TId id, string r await _setRelationship.SetRelationshipAsync(id, relationshipName, relationships); return Ok(); } - + + /// + /// Deletes a resource. + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); + await _delete.DeleteAsync(id); + + return NoContent(); + } + /// /// Removes resources from a to-many relationship. /// @@ -250,8 +244,6 @@ public virtual async Task DeleteRelationshipAsync(TId id, string await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, relationships); return Ok(); } - - #endregion } /// @@ -278,19 +270,18 @@ protected BaseJsonApiController( protected BaseJsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IUpdateService update = null, - IDeleteService delete = null, - IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, + ICreateService create = null, + IAddRelationshipService addRelationship = null, + IUpdateService update = null, ISetRelationshipService setRelationship = null, - IDeleteRelationshipService deleteRelationship = null - ) - : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, - addRelationship, getRelationship, setRelationship, deleteRelationship) + IDeleteService delete = null, + IDeleteRelationshipService deleteRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, update, + setRelationship, delete, deleteRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 8aa98d04ba..fb8ab95b21 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -29,25 +28,20 @@ public JsonApiController( public JsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IUpdateService update = null, - IDeleteService delete = null, - IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, + ICreateService create = null, + IAddRelationshipService addRelationship = null, + IUpdateService update = null, ISetRelationshipService setRelationship = null, + IDeleteService delete = null, IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, addRelationship, - getRelationship, setRelationship, deleteRelationship) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, update, + setRelationship, delete, deleteRelationship) { } - /// - [HttpPost] - public override async Task PostAsync([FromBody] TResource resource) - => await base.PostAsync(resource); - /// [HttpGet] public override async Task GetAsync() => await base.GetAsync(); @@ -55,12 +49,21 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - + + /// + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); - + + /// + [HttpPost] + public override async Task PostAsync([FromBody] TResource resource) + => await base.PostAsync(resource); /// [HttpPatch("{id}")] @@ -69,30 +72,15 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc return await base.PatchAsync(id, resource); } - /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); - - /// - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.PostRelationshipAsync(id, relationshipName, relationships); - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - /// [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + public override async Task PatchRelationshipAsync( + TId id, string relationshipName, [FromBody] object relationships) => await base.PatchRelationshipAsync(id, relationshipName, relationships); - + /// - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.DeleteRelationshipAsync(id, relationshipName, relationships); + [HttpDelete("{id}")] + public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); } /// @@ -110,18 +98,18 @@ public JsonApiController( public JsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IUpdateService update = null, - IDeleteService delete = null, - IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, + ICreateService create = null, + IAddRelationshipService addRelationship = null, + IUpdateService update = null, ISetRelationshipService setRelationship = null, + IDeleteService delete = null, IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, addRelationship, - getRelationship, setRelationship, deleteRelationship) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, update, + setRelationship, delete, deleteRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs index 52834b2a23..06aee75847 100644 --- a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs @@ -15,6 +15,6 @@ public interface IAddRelationshipService where TResource : cl /// /// Handles a json:api request to add resources to a to-many relationship. /// - Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relatinshipValues); + Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index d91071ce59..d352e76712 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -305,15 +305,15 @@ public virtual async Task DeleteAsync(TId id) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relatinshipValues) + public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships = relatinshipValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationships = relationshipValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(relationshipName); - await AssertRelationshipValuesExistAsync((_request.Relationship, relatinshipValues)); + await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipValues)); var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); queryLayer.Include = IncludeRelationship(_request.Relationship); @@ -323,7 +323,7 @@ public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumera AssertPrimaryResourceExists(primaryResource); var relationshipValueCollection = ((IEnumerable) _request.Relationship.GetValue(primaryResource)).Select(i => i.StringId).ToList(); - foreach (var entry in relatinshipValues) + foreach (var entry in relationshipValues) { if (!relationshipValueCollection.Contains(entry.StringId)) { @@ -421,12 +421,20 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnum AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(relationshipName); - await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipValues)); - + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); queryLayer.Include = IncludeRelationship(_request.Relationship); queryLayer.Filter = IncludeFilterById(id, null); + /* + * We are fetching resources plus related + * in most ideal scenario + * one to many: clear FK + * many to many: clear join table record + * no resources need to be fetched. + * implicit removes: don't exist, because we're explicitly removing + * complete replacement: not what we're doing. + */ var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); @@ -440,7 +448,6 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnum } await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipValueCollection); - } private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 8abd09d00e..5788e16ded 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -168,7 +168,7 @@ private class IntResourceService : IResourceService public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object relationshipValues) => throw new NotImplementedException(); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relatinshipValues) => throw new NotImplementedException(); + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } @@ -182,7 +182,7 @@ private class GuidResourceService : IResourceService public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipValues) => throw new NotImplementedException(); - public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relatinshipValues) => throw new NotImplementedException(); + public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } From 842da90b82aec7e0935ab1beb7c20a11a615c95c Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 08:15:12 +0200 Subject: [PATCH 018/240] feat: draft redirect UpdateRelationships to Update --- .../Controllers/BaseJsonApiController.cs | 33 ++- .../Controllers/JsonApiCommandController.cs | 17 +- .../Controllers/JsonApiController.cs | 26 +- .../Controllers/JsonApiQueryController.cs | 12 +- .../EntityFrameworkCoreRepository.cs | 13 +- .../Serialization/RequestDeserializer.cs | 5 +- .../Services/JsonApiResourceService.cs | 230 +++++++++--------- .../Spec/UpdatingRelationshipsTests.cs | 2 +- 8 files changed, 181 insertions(+), 157 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 920f18f7c6..f176999121 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -39,8 +39,7 @@ protected BaseJsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : this(options, loggerFactory, resourceService, resourceService, resourceService, resourceService, - resourceService, resourceService, resourceService, resourceService) + : this(options, loggerFactory, resourceService, resourceService) { } /// @@ -113,35 +112,35 @@ public virtual async Task GetAsync(TId id) var resource = await _getById.GetAsync(id); return Ok(resource); } - + /// - /// Gets a single resource relationship. - /// Example: GET /articles/1/relationships/author HTTP/1.1 + /// Gets a single resource or multiple resources at a nested endpoint. + /// Examples: + /// GET /articles/1/author HTTP/1.1 + /// GET /articles/1/revisions HTTP/1.1 /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); - + if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } - + /// - /// Gets a single resource or multiple resources at a nested endpoint. - /// Examples: - /// GET /articles/1/author HTTP/1.1 - /// GET /articles/1/revisions HTTP/1.1 + /// Gets a single resource relationship. + /// Example: GET /articles/1/relationships/author HTTP/1.1 /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + return Ok(relationship); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 2bc4e4a502..4ff3586813 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -30,6 +30,12 @@ protected JsonApiCommandController( public override async Task PostAsync([FromBody] TResource resource) => await base.PostAsync(resource); + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + TId id, string relationshipName, [FromBody] IEnumerable relationships) + => await base.PostRelationshipAsync(id, relationshipName, relationships); + /// [HttpPatch("{id}")] public override async Task PatchAsync(TId id, [FromBody] TResource resource) @@ -40,16 +46,15 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc public override async Task PatchRelationshipAsync( TId id, string relationshipName, [FromBody] object relationships) => await base.PatchRelationshipAsync(id, relationshipName, relationships); - - /// - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.PostRelationshipAsync(id, relationshipName, relationships); /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); + + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) + => await base.DeleteRelationshipAsync(id, relationshipName, relationships); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index fb8ab95b21..9783eb33d0 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -38,7 +39,7 @@ public JsonApiController( ISetRelationshipService setRelationship = null, IDeleteService delete = null, IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, update, + : base(options, loggerFactory,getAll, getById, getSecondary, getRelationship, create, addRelationship, update, setRelationship, delete, deleteRelationship) { } @@ -49,22 +50,28 @@ public JsonApiController( /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); + + /// + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); /// [HttpPost] public override async Task PostAsync([FromBody] TResource resource) => await base.PostAsync(resource); + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + TId id, string relationshipName, [FromBody] IEnumerable relationships) + => await base.PostRelationshipAsync(id, relationshipName, relationships); + /// [HttpPatch("{id}")] public override async Task PatchAsync(TId id, [FromBody] TResource resource) @@ -81,6 +88,11 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); + + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) + => await base.DeleteRelationshipAsync(id, relationshipName, relationships); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 89af9d95c8..5823da8499 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -31,16 +31,16 @@ protected JsonApiQueryController( /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); + + /// + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); } /// diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 77c58d3664..1ffb8bd734 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -384,18 +384,23 @@ public virtual void FlushFromCache(TResource resource) /// after which the reassignment `p1.todoItems = [t3, t4]` will actually /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. /// - protected void LoadCurrentRelationships(TResource oldResource, RelationshipAttribute relationshipAttribute) + protected void LoadCurrentRelationships(TResource databaseResource, RelationshipAttribute relationshipAttribute) { - if (oldResource == null) throw new ArgumentNullException(nameof(oldResource)); + if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); + // if (_dbContext.Set().Local.All(e => e.StringId != databaseResource.StringId)) + // { + // _dbContext.Entry(databaseResource).State = EntityState.Unchanged; + // } + if (relationshipAttribute is HasManyThroughAttribute throughAttribute) { - _dbContext.Entry(oldResource).Collection(throughAttribute.ThroughProperty.Name).Load(); + _dbContext.Entry(databaseResource).Collection(throughAttribute.ThroughProperty.Name).Load(); } else if (relationshipAttribute is HasManyAttribute hasManyAttribute) { - _dbContext.Entry(oldResource).Collection(hasManyAttribute.Property.Name).Load(); + _dbContext.Entry(databaseResource).Collection(hasManyAttribute.Property.Name).Load(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index cc8250ba57..70119ad183 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -32,7 +32,10 @@ public object Deserialize(string body) { if (body == null) throw new ArgumentNullException(nameof(body)); - return DeserializeBody(body); + var deserialized = DeserializeBody(body); + + if () + } /// diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index d352e76712..4666871e7e 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -68,38 +68,7 @@ public JsonApiResourceService( _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _hookExecutor = hookExecutor; } - - /// - // triggered by POST /articles - public virtual async Task CreateAsync(TResource resource) - { - _traceWriter.LogMethodStart(new {resource}); - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - if (_hookExecutor != null) - { - resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); - } - - if (HasNonNullRelationshipAssignments(resource, out var assignments)) - { - await AssertRelationshipValuesExistAsync(assignments); - } - - await _repository.CreateAsync(resource); - - resource = await GetPrimaryResourceById(resource.Id, true); - - - if (_hookExecutor != null) - { - _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); - resource = _hookExecutor.OnReturn(AsList(resource), ResourcePipeline.Post).Single(); - } - - return resource; - } - + /// public virtual async Task> GetAsync() { @@ -235,74 +204,68 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN return secondaryResource; } - /// \ - // triggered by PATCH /articles/{id} - public virtual async Task UpdateAsync(TId id, TResource requestResource) + /// + // triggered by GET /articles/{id}/relationships/{relationshipName} + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - _traceWriter.LogMethodStart(new {id, requestResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - - TResource databaseResource = await GetPrimaryResourceById(id, false); + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) - { - await AssertRelationshipValuesExistAsync(assignments); - } - - _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); - _resourceChangeTracker.SetRequestedAttributeValues(requestResource); + AssertRelationshipExists(relationshipName); - if (_hookExecutor != null) - { - requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); - } + _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - await _repository.UpdateAsync(requestResource, databaseResource); + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); + secondaryLayer.Include = null; + + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + + var primaryResources = await _repository.GetAsync(primaryLayer); + + var primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); if (_hookExecutor != null) { - _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(databaseResource), ResourcePipeline.Patch); + _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); + primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); } - _repository.FlushFromCache(databaseResource); - TResource afterResource = await GetPrimaryResourceById(id, false); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResource); - - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterResource : null; + return primaryResource; } /// - // triggered by DELETE /articles/{id - public virtual async Task DeleteAsync(TId id) + // triggered by POST /articles + public virtual async Task CreateAsync(TResource resource) { - _traceWriter.LogMethodStart(new {id}); + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); if (_hookExecutor != null) { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); + resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); } - - var succeeded = await _repository.DeleteAsync(id); - - if (_hookExecutor != null) + + if (HasNonNullRelationshipAssignments(resource, out var assignments)) { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); + await AssertRelationshipValuesExistAsync(assignments); } + + await _repository.CreateAsync(resource); - if (!succeeded) + resource = await GetPrimaryResourceById(resource.Id, true); + + + if (_hookExecutor != null) { - AssertPrimaryResourceExists(null); + _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); + resource = _hookExecutor.OnReturn(AsList(resource), ResourcePipeline.Post).Single(); } + + return resource; } - + /// // triggered by POST /articles/{id}/relationships/{relationshipName} public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues) @@ -335,36 +298,43 @@ public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumera } /// - // triggered by GET /articles/{id}/relationships/{relationshipName} - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + // triggered by PATCH /articles/{id} + public virtual async Task UpdateAsync(TId id, TResource requestResource) { - _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - - AssertRelationshipExists(relationshipName); - - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; + _traceWriter.LogMethodStart(new {id, requestResource}); + if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + + TResource databaseResource = await GetPrimaryResourceById(id, false); - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) + { + await AssertRelationshipValuesExistAsync(assignments); + } + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); + _resourceChangeTracker.SetRequestedAttributeValues(requestResource); - var primaryResources = await _repository.GetAsync(primaryLayer); + if (_hookExecutor != null) + { + requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); + } - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + await _repository.UpdateAsync(requestResource, databaseResource); if (_hookExecutor != null) { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); + _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); + _hookExecutor.OnReturn(AsList(databaseResource), ResourcePipeline.Patch); } - return primaryResource; - } + _repository.FlushFromCache(databaseResource); + TResource afterResource = await GetPrimaryResourceById(id, false); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResource); + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? afterResource : null; + } + /// // triggered by PATCH /articles/{id}/relationships/{relationshipName} public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationshipValues) @@ -374,16 +344,12 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; - - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id); - primaryLayer.Projection = null; + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + // Todo: add projection on primary key for both root and nested resource; that's all we need. + queryLayer.Include = IncludeRelationship(_request.Relationship); + queryLayer.Filter = IncludeFilterById(id, null); - var primaryResources = await _repository.GetAsync(primaryLayer); - - var primaryResource = primaryResources.SingleOrDefault(); + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); if (relationshipValues != null) @@ -396,22 +362,56 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); } - string[] relationshipIds = null; - if (relationshipValues != null) + var requestResource = _resourceFactory.CreateInstance(); + requestResource.StringId = primaryResource.StringId; + if (_request.Relationship is HasManyAttribute) { - relationshipIds = _request.Relationship is HasOneAttribute - ? new[] {TypeHelper.GetIdValue((IIdentifiable) relationshipValues)} - : ((IEnumerable) relationshipValues).Select(TypeHelper.GetIdValue).ToArray(); + var collection = (IEnumerable)relationshipValues; + _request.Relationship.SetValue(requestResource, TypeHelper.CopyToTypedCollection(collection, _request.Relationship.Property.PropertyType), _resourceFactory); } - - await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); - + else + { + _request.Relationship.SetValue(requestResource, relationshipValues, _resourceFactory); + } + + await _repository.UpdateAsync(requestResource, primaryResource); + if (_hookExecutor != null && primaryResource != null) { _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); } } + /// + // triggered by DELETE /articles/{id + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + if (_hookExecutor != null) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); + } + + var succeeded = await _repository.DeleteAsync(id); + + if (_hookExecutor != null) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); + } + + if (!succeeded) + { + AssertPrimaryResourceExists(null); + } + } + /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues) @@ -521,7 +521,7 @@ private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttrib throw new ResourceNotFoundException(nonExistingResources); } } - + private IncludeExpression IncludeRelationship(RelationshipAttribute relationship) { return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 802345aa5d..22c4555aa2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -753,7 +753,7 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() // Act var response = await client.SendAsync(request); ; - + var responseBody = await response.Content.ReadAsStringAsync(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); _context = _fixture.GetRequiredService(); From b96fe65464844e0dae6b78739963bd25b158c1a6 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 08:32:54 +0200 Subject: [PATCH 019/240] chore: self review --- .../Controllers/ReportsController.cs | 2 +- .../Configuration/ServiceDiscoveryFacade.cs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index 54fbc24bff..26c42c29f2 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -15,7 +15,7 @@ public ReportsController( IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll) - : base(options, loggerFactory, getAll: getAll) + : base(options, loggerFactory, getAll) { } [HttpGet] diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index ddae1efc20..ddd8593f5a 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -24,24 +24,24 @@ public class ServiceDiscoveryFacade typeof(IResourceCommandService<,>), typeof(IResourceQueryService<>), typeof(IResourceQueryService<,>), - typeof(ICreateService<>), - typeof(ICreateService<,>), typeof(IGetAllService<>), typeof(IGetAllService<,>), typeof(IGetByIdService<>), typeof(IGetByIdService<,>), typeof(IGetSecondaryService<>), typeof(IGetSecondaryService<,>), - typeof(IUpdateService<>), - typeof(IUpdateService<,>), - typeof(IDeleteService<>), - typeof(IDeleteService<,>), - typeof(IAddRelationshipService<>), - typeof(IAddRelationshipService<,>), typeof(IGetRelationshipService<>), typeof(IGetRelationshipService<,>), + typeof(ICreateService<>), + typeof(ICreateService<,>), + typeof(IAddRelationshipService<>), + typeof(IAddRelationshipService<,>), + typeof(IUpdateService<>), + typeof(IUpdateService<,>), typeof(ISetRelationshipService<>), typeof(ISetRelationshipService<,>), + typeof(IDeleteService<>), + typeof(IDeleteService<,>), typeof(IDeleteRelationshipService<>), typeof(IDeleteRelationshipService<,>) }; From e6bfc57394f9fa69da2d3d2da78aa8e2c53b7de5 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 08:51:12 +0200 Subject: [PATCH 020/240] chore: self review --- .../Services/WorkItemService.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 6 +- .../Controllers/JsonApiCommandController.cs | 4 +- .../Controllers/JsonApiQueryController.cs | 4 +- .../RequestMethodNotAllowedException.cs | 7 +- .../Queries/IQueryLayerComposer.cs | 2 +- .../Queries/Internal/QueryLayerComposer.cs | 17 +-- .../EntityFrameworkCoreRepository.cs | 15 +- .../IRepositoryRelationshipUpdateHelper.cs | 26 ---- .../Repositories/IResourceWriteRepository.cs | 2 +- .../RepositoryRelationshipUpdateHelper.cs | 128 ------------------ .../Client/Internal/RequestSerializer.cs | 23 +--- .../Services/IResourceCommandService.cs | 8 +- .../Services/ISetRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 22 +-- .../Acceptance/Spec/CreatingDataTests.cs | 3 +- .../IServiceCollectionExtensionsTests.cs | 4 +- 17 files changed, 43 insertions(+), 232 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs delete mode 100644 src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 617c1c66b3..959dbf300d 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -67,7 +67,7 @@ public Task UpdateAsync(int id, WorkItem requestResource) throw new NotImplementedException(); } - public Task SetRelationshipAsync(int id, string relationshipName, object relationshipValues) + public Task SetRelationshipAsync(int id, string relationshipName, object relationshipAssignment) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index f176999121..25219b3a9f 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -112,7 +112,7 @@ public virtual async Task GetAsync(TId id) var resource = await _getById.GetAsync(id); return Ok(resource); } - + /// /// Gets a single resource or multiple resources at a nested endpoint. /// Examples: @@ -128,7 +128,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } - + /// /// Gets a single resource relationship. /// Example: GET /articles/1/relationships/author HTTP/1.1 @@ -215,6 +215,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); await _setRelationship.SetRelationshipAsync(id, relationshipName, relationships); + return Ok(); } @@ -241,6 +242,7 @@ public virtual async Task DeleteRelationshipAsync(TId id, string if (_deleteRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, relationships); + return Ok(); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 4ff3586813..4d3957231e 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -46,11 +46,11 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc public override async Task PatchRelationshipAsync( TId id, string relationshipName, [FromBody] object relationships) => await base.PatchRelationshipAsync(id, relationshipName, relationships); - + /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); - + /// [HttpDelete("{id}/relationships/{relationshipName}")] public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 5823da8499..4ca4e8d361 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -31,12 +31,12 @@ protected JsonApiQueryController( /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); - + /// [HttpGet("{id}/relationships/{relationshipName}")] public override async Task GetRelationshipAsync(TId id, string relationshipName) diff --git a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs index ca99e0608c..1d65132f3e 100644 --- a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http; -using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Errors @@ -16,17 +15,17 @@ public RequestMethodNotAllowedException(HttpMethod method) : base(new Error(HttpStatusCode.MethodNotAllowed) { Title = "The request method is not allowed.", - Detail = $"Resource does not support {method} requests." + Detail = $"Resource does not support {method} requests." }) { Method = method; } - public RequestMethodNotAllowedException(string mismatchingRelationshipType) + public RequestMethodNotAllowedException(string toOneRelationship) : base(new Error(HttpStatusCode.MethodNotAllowed) { Title = "The request method is not allowed.", - Detail = $"Relationship {mismatchingRelationshipType} is not a to-many relationship." + Detail = $"Relationship {toOneRelationship} is not a to-many relationship." }) { } } } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index 72ed02ef95..dd2657e6a2 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -24,7 +24,7 @@ public interface IQueryLayerComposer /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. /// QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, - TId primaryId, RelationshipAttribute secondaryRelationship = null); + TId primaryId, RelationshipAttribute secondaryRelationship); /// /// Gets the secondary projection for a relationship endpoint. diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 51127f7b71..58d723f13f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -177,7 +177,7 @@ private static IReadOnlyCollection ApplyIncludeElement } /// - public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship = null) + public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship) { var innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; @@ -186,26 +186,17 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, var sparseFieldSet = new SparseFieldSetExpression(new[] { primaryIdAttribute }); var primaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, primaryResourceContext) ?? new Dictionary(); - if (secondaryRelationship != null) - { - primaryProjection[secondaryRelationship] = secondaryLayer; - } + primaryProjection[secondaryRelationship] = secondaryLayer; primaryProjection[primaryIdAttribute] = null; var primaryFilter = GetFilter(Array.Empty(), primaryResourceContext); - var queryLayer = new QueryLayer(primaryResourceContext) + return new QueryLayer(primaryResourceContext) { + Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), Filter = IncludeFilterById(primaryId, primaryResourceContext, primaryFilter), Projection = primaryProjection }; - - if (secondaryRelationship != null) - { - queryLayer.Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship); - } - - return queryLayer; } private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude, RelationshipAttribute secondaryRelationship) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 92ec96734f..322bbe8481 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -111,11 +111,11 @@ public virtual async Task CreateAsync(TResource resource) { _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - + foreach (var relationshipAttr in _targetedFields.Relationships) { var relationshipIds = GetRelationshipIds(relationshipAttr, resource); - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, relationshipIds.ToArray() ); + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource); LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); } @@ -214,7 +214,7 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab foreach (var relationshipAttr in _targetedFields.Relationships) { - // loads databasePerson.todoItems. Required for complete replacements + // Ensures complete replacements of relationships. LoadCurrentRelationships(databaseResource, relationshipAttr); // trackedRelationshipValue is either equal to updatedPerson.todoItems, @@ -234,14 +234,7 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab await _dbContext.SaveChangesAsync(); } - - /// - /// Responsible for getting the relationship value for a given relationship - /// attribute of a given resource. It ensures that the relationship value - /// that it returns is attached to the database without reattaching duplicates instances - /// to the change tracker. It does so by checking if there already are - /// instances of the to-be-attached entities in the change tracker. - /// + private string[] GetRelationshipIds(RelationshipAttribute relationship, TResource requestResource) { if (relationship is HasOneAttribute hasOneAttr) diff --git a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs deleted file mode 100644 index a67d408e38..0000000000 --- a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// A helper dedicated to processing updates of relationships - /// - /// - /// This service is required to be able translate involved expressions into queries - /// instead of having them evaluated on the client side. In particular, for all three types of relationship - /// a lookup is performed based on an ID. Expressions that use IIdentifiable.StringId can never - /// be translated into queries because this property only exists at runtime after the query is performed. - /// We will have to build expression trees if we want to use IIdentifiable{TId}.TId, for which we minimally a - /// generic execution to DbContext.Set{T}(). - /// - public interface IRepositoryRelationshipUpdateHelper - { - /// - /// Processes updates of relationships - /// - Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); - } -} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 3d6d16583d..3b277d49bc 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -35,7 +35,7 @@ public interface IResourceWriteRepository /// Updates a relationship in the underlying data store. /// Task SetRelationshipsAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); - + /// /// Deletes a resource from the underlying data store. /// diff --git a/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs deleted file mode 100644 index d349abe17c..0000000000 --- a/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Repositories -{ - /// - public class RepositoryRelationshipUpdateHelper : IRepositoryRelationshipUpdateHelper where TRelatedResource : class - { - private readonly IResourceFactory _resourceFactory; - private readonly DbContext _context; - - public RepositoryRelationshipUpdateHelper(IDbContextResolver contextResolver, IResourceFactory resourceFactory) - { - if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); - - _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _context = contextResolver.GetContext(); - } - - /// - public virtual async Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - if (parent == null) throw new ArgumentNullException(nameof(parent)); - if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); - - if (relationship is HasManyThroughAttribute hasManyThrough) - await UpdateManyToManyAsync(parent, hasManyThrough, relationshipIds); - else if (relationship is HasManyAttribute) - await UpdateOneToManyAsync(parent, relationship, relationshipIds); - else - await UpdateOneToOneAsync(parent, relationship, relationshipIds); - } - - private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - TRelatedResource value = null; - if (relationshipIds.Any()) - { // newOwner.id - var target = Expression.Constant(TypeHelper.ConvertType(relationshipIds.First(), TypeHelper.GetIdType(relationship.RightType))); - // (Person p) => ... - ParameterExpression parameter = Expression.Parameter(typeof(TRelatedResource)); - // (Person p) => p.Id - Expression idMember = Expression.Property(parameter, nameof(Identifiable.Id)); - // newOwner.Id.Equals(p.Id) - Expression callEquals = Expression.Call(idMember, nameof(object.Equals), null, target); - var equalsLambda = Expression.Lambda>(callEquals, parameter); - value = await _context.Set().FirstOrDefaultAsync(equalsLambda); - } - relationship.SetValue(parent, value, _resourceFactory); - } - - private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - IEnumerable value; - if (!relationshipIds.Any()) - { - var collectionType = TypeHelper.ToConcreteCollectionType(relationship.Property.PropertyType); - value = (IEnumerable)TypeHelper.CreateInstance(collectionType); - } - else - { - var idType = TypeHelper.GetIdType(relationship.RightType); - var typedIds = TypeHelper.CopyToList(relationshipIds, idType, stringId => TypeHelper.ConvertType(stringId, idType)); - - // [1, 2, 3] - var target = Expression.Constant(typedIds); - // (Person p) => ... - ParameterExpression parameter = Expression.Parameter(typeof(TRelatedResource)); - // (Person p) => p.Id - Expression idMember = Expression.Property(parameter, nameof(Identifiable.Id)); - // [1,2,3].Contains(p.Id) - var callContains = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] { idMember.Type }, target, idMember); - var containsLambda = Expression.Lambda>(callContains, parameter); - - var resultSet = await _context.Set().Where(containsLambda).ToListAsync(); - value = TypeHelper.CopyToTypedCollection(resultSet, relationship.Property.PropertyType); - } - - relationship.SetValue(parent, value, _resourceFactory); - } - - private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAttribute relationship, IReadOnlyCollection relationshipIds) - { - // we need to create a transaction for the HasManyThrough case so we can get and remove any existing - // through resources and only commit if all operations are successful - var transaction = await _context.GetCurrentOrCreateTransactionAsync(); - // ArticleTag - ParameterExpression parameter = Expression.Parameter(relationship.ThroughType); - // ArticleTag.ArticleId - Expression idMember = Expression.Property(parameter, relationship.LeftIdProperty); - // article.Id - var parentId = TypeHelper.ConvertType(parent.StringId, relationship.LeftIdProperty.PropertyType); - Expression target = Expression.Constant(parentId); - // ArticleTag.ArticleId.Equals(article.Id) - Expression callEquals = Expression.Call(idMember, "Equals", null, target); - var lambda = Expression.Lambda>(callEquals, parameter); - // TODO: we shouldn't need to do this instead we should try updating the existing? - // the challenge here is if a composite key is used, then we will fail to - // create due to a unique key violation - var oldLinks = _context - .Set() - .Where(lambda.Compile()) - .ToList(); - - _context.RemoveRange(oldLinks); - - var newLinks = relationshipIds.Select(x => - { - var link = _resourceFactory.CreateInstance(relationship.ThroughType); - relationship.LeftIdProperty.SetValue(link, TypeHelper.ConvertType(parentId, relationship.LeftIdProperty.PropertyType)); - relationship.RightIdProperty.SetValue(link, TypeHelper.ConvertType(x, relationship.RightIdProperty.PropertyType)); - return link; - }); - - _context.AddRange(newLinks); - await _context.SaveChangesAsync(); - await transaction.CommitAsync(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs index e56ba3ee8a..6c88aaf723 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs @@ -36,7 +36,7 @@ public string Serialize(IIdentifiable resource) } _currentTargetedResource = resource.GetType(); - var document = Build(resource, GetAttributesToSerialize(resource), GetRelationshipsToSerialize(resource)); + var document = Build(resource, GetAttributesToSerialize(resource), RelationshipsToSerialize); _currentTargetedResource = null; return SerializeObject(document, _jsonSerializerSettings); @@ -58,9 +58,8 @@ public string Serialize(IReadOnlyCollection resources) { _currentTargetedResource = firstResource.GetType(); var attributes = GetAttributesToSerialize(firstResource); - var relationships = GetRelationshipsToSerialize(firstResource); - document = Build(resources, attributes, relationships); + document = Build(resources, attributes, RelationshipsToSerialize); _currentTargetedResource = null; } @@ -91,23 +90,5 @@ private IReadOnlyCollection GetAttributesToSerialize(IIdentifiabl return AttributesToSerialize; } - - /// - /// By default, the client serializer does not include any relationships - /// for resources in the primary data unless explicitly included using - /// . - /// - private IReadOnlyCollection GetRelationshipsToSerialize(IIdentifiable resource) - { - return RelationshipsToSerialize; - var currentResourceType = resource.GetType(); - // only allow relationship attributes to be serialized if they were set using - // - // and the current resource is a primary entry. - if (RelationshipsToSerialize == null) - return _resourceGraph.GetRelationships(currentResourceType); - - return RelationshipsToSerialize; - } } } diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 566b9b736a..656089c031 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -8,10 +8,10 @@ namespace JsonApiDotNetCore.Services /// The resource type. public interface IResourceCommandService : ICreateService, - IUpdateService, - IDeleteService, IAddRelationshipService, + IUpdateService, ISetRelationshipService, + IDeleteService, IDeleteRelationshipService, IResourceCommandService where TResource : class, IIdentifiable @@ -24,10 +24,10 @@ public interface IResourceCommandService : /// The resource identifier type. public interface IResourceCommandService : ICreateService, - IUpdateService, - IDeleteService, IAddRelationshipService, + IUpdateService, ISetRelationshipService, + IDeleteService, IDeleteRelationshipService where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 8b501365b7..692daf2b66 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -14,6 +14,6 @@ public interface ISetRelationshipService where TResource : cl /// /// Handles a json:api request to update an existing relationship. /// - Task SetRelationshipAsync(TId id, string relationshipName, object relationshipValues); + Task SetRelationshipAsync(TId id, string relationshipName, object relationshipAssignment); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 4666871e7e..cd0c89dfb4 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -68,7 +68,7 @@ public JsonApiResourceService( _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _hookExecutor = hookExecutor; } - + /// public virtual async Task> GetAsync() { @@ -334,12 +334,12 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); return hasImplicitChanges ? afterResource : null; } - + /// // triggered by PATCH /articles/{id}/relationships/{relationshipName} - public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationshipValues) + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationshipAssignment) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipValues = relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -352,9 +352,9 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (relationshipValues != null) + if (relationshipAssignment != null) { - await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipValues)); + await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipAssignment)); } if (_hookExecutor != null) @@ -366,12 +366,12 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, requestResource.StringId = primaryResource.StringId; if (_request.Relationship is HasManyAttribute) { - var collection = (IEnumerable)relationshipValues; + var collection = (IEnumerable)relationshipAssignment; _request.Relationship.SetValue(requestResource, TypeHelper.CopyToTypedCollection(collection, _request.Relationship.Property.PropertyType), _resourceFactory); } else { - _request.Relationship.SetValue(requestResource, relationshipValues, _resourceFactory); + _request.Relationship.SetValue(requestResource, relationshipAssignment, _resourceFactory); } await _repository.UpdateAsync(requestResource, primaryResource); @@ -466,7 +466,7 @@ private bool HasNonNullRelationshipAssignments(TResource requestResource, out (R return assignments.Any(); } - + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -521,12 +521,12 @@ private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttrib throw new ResourceNotFoundException(nonExistingResources); } } - + private IncludeExpression IncludeRelationship(RelationshipAttribute relationship) { return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); } - + private List AsList(TResource resource) { return new List { resource }; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index e8d0b27dcc..ae15051ab4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -347,8 +347,7 @@ public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() newPerson.Passport = passport; // Act - var requestBody = serializer.Serialize(newPerson); - var (body, response) = await Post("/api/v1/people", requestBody); + var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); // Assert AssertEqualStatusCode(HttpStatusCode.Created, response); diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 5788e16ded..432034816c 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -167,7 +167,7 @@ private class IntResourceService : IResourceService public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(int id, string relationshipName, object relationshipValues) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } @@ -181,7 +181,7 @@ private class GuidResourceService : IResourceService public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipValues) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } From cedbea1f26685e46752ec3f6df8e1b25fe0bdaf6 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 09:32:46 +0200 Subject: [PATCH 021/240] chore: self review --- .../JsonApiApplicationBuilder.cs | 1 - .../Repositories/DbContextExtensions.cs | 17 +-- .../EntityFrameworkCoreRepository.cs | 122 +++++++----------- .../Acceptance/Spec/UpdatingDataTests.cs | 26 ++-- .../Spec/UpdatingRelationshipsTests.cs | 33 ++--- 5 files changed, 80 insertions(+), 119 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 188554cd8c..747082609b 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -145,7 +145,6 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) } _services.AddScoped(); - _services.AddScoped(typeof(RepositoryRelationshipUpdateHelper<>)); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(); _services.AddScoped(); diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 7d8c485b1f..1091e21815 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -18,25 +18,14 @@ internal static TEntity GetTrackedEntity(this DbContext context, TEntit { if (entity == null) throw new ArgumentNullException(nameof(entity)); - return (TEntity)context.GetTrackedEntity(typeof(TEntity), entity.StringId); - } - - internal static IIdentifiable GetTrackedEntity(this DbContext context, Type entityType, string id) - { - if (entityType == null) throw new ArgumentNullException(nameof(entityType)); - - if (id == null) - { - return null; - } - + var entityType = entity.GetType(); var entityEntry = context.ChangeTracker .Entries() .FirstOrDefault(entry => entry.Entity.GetType() == entityType && - ((IIdentifiable) entry.Entity).StringId == id); + ((IIdentifiable) entry.Entity).StringId == entity.StringId); - return (IIdentifiable)entityEntry?.Entity; + return (TEntity)entityEntry?.Entity; } /// diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 322bbe8481..5bf0171923 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -114,10 +114,7 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationshipAttr in _targetedFields.Relationships) { - var relationshipIds = GetRelationshipIds(relationshipAttr, resource); - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource); - LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); + ApplyRelationshipAssignment(resource, relationshipAttr); } _dbContext.Set().Add(resource); @@ -216,86 +213,60 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab { // Ensures complete replacements of relationships. LoadCurrentRelationships(databaseResource, relationshipAttr); - - // trackedRelationshipValue is either equal to updatedPerson.todoItems, - // or replaced with the same set (same ids) of todoItems from the EF Core change tracker, - // which is the case if they were already tracked - var relationshipIds = GetRelationshipIds(relationshipAttr, requestResource); - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, relationshipIds); - - // loads into the db context any persons currently related - // to the todoItems in trackedRelationshipValue - LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - - // assigns the updated relationship to the database resource - //AssignRelationshipValue(databaseResource, trackedRelationshipValue, relationshipAttr); - relationshipAttr.SetValue(databaseResource, trackedRelationshipValue, _resourceFactory); + + ApplyRelationshipAssignment(requestResource, relationshipAttr, databaseResource); } await _dbContext.SaveChangesAsync(); } - private string[] GetRelationshipIds(RelationshipAttribute relationship, TResource requestResource) + private void ApplyRelationshipAssignment(TResource requestResource, RelationshipAttribute relationshipAttr, TResource targetResource = null) { - if (relationship is HasOneAttribute hasOneAttr) - { - var relationshipValue = (IIdentifiable) hasOneAttr.GetValue(requestResource); + // Ensures the new relationship assignment will not result entities being tracked more than once. + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource); + + // Ensures successful handling of implicit removals of relationships. + LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); + + relationshipAttr.SetValue(targetResource ?? requestResource, trackedRelationshipValue, _resourceFactory); + } - if (relationshipValue == null) - { - return new string[0]; - } + private object GetTrackedRelationshipValue(RelationshipAttribute relationship, object requestResource) + { + object relationshipAssignment = relationship.GetValue(requestResource); + object trackedRelationshipAssignment; - return new[] { relationshipValue.StringId }; - } - else + if (relationshipAssignment == null) { - var hasManyAttr = (HasManyAttribute)relationship; - var relationshipValuesCollection = (IEnumerable)hasManyAttr.GetValue(requestResource); - - return relationshipValuesCollection.Select(i => i.StringId).ToArray(); - } - } - - private object GetTrackedRelationshipValue(RelationshipAttribute relationship, params string[] relationshipIds) - { - object trackedRelationshipValue; - var entityType = relationship.RightType; - - if (relationship is HasOneAttribute) + trackedRelationshipAssignment = null; + } + else if (relationshipAssignment is IIdentifiable hasOneAssignment) { - if (!relationshipIds.Any()) - { - return null; - } - - var id = relationshipIds.Single(); - trackedRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, id); + trackedRelationshipAssignment = GetTrackedOrNewlyAttachedEntity(hasOneAssignment); } else { - var amountOfValues = relationshipIds.Count(); - var collection = new object[amountOfValues]; + var hasManyAssignment = ((IEnumerable)relationshipAssignment).ToArray(); + var collection = new object[hasManyAssignment.Length]; - for (int i = 0; i < amountOfValues; i++) + for (int i = 0; i < hasManyAssignment.Length; i++) { - var trackedElementOfRelationshipValue = GetTrackedOrNewlyAttachedEntity(entityType, relationshipIds[i]); + var trackedHasManyElement = GetTrackedOrNewlyAttachedEntity(hasManyAssignment[i]); // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. - var conversionTarget = trackedElementOfRelationshipValue.GetType(); - collection[i] = Convert.ChangeType(trackedElementOfRelationshipValue, conversionTarget); + var conversionTarget = trackedHasManyElement.GetType(); + collection[i] = Convert.ChangeType(trackedHasManyElement, conversionTarget); } - trackedRelationshipValue = TypeHelper.CopyToTypedCollection(collection, relationship.Property.PropertyType); + trackedRelationshipAssignment = TypeHelper.CopyToTypedCollection(collection, relationship.Property.PropertyType); } - - - return trackedRelationshipValue; + + return trackedRelationshipAssignment; } - private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string id) + private IIdentifiable GetTrackedOrNewlyAttachedEntity(IIdentifiable entity) { - var trackedEntity = _dbContext.GetTrackedEntity(resourceType, id); + var trackedEntity = _dbContext.GetTrackedEntity(entity); if (trackedEntity == null) { // the relationship pointer is new to EF Core, but we are sure @@ -303,9 +274,8 @@ private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string // the json:api spec, we can also safely assume that no fields of // this resource were updated. Note that if it was already tracked, reattaching it // will throw an error when calling dbContext.SaveAsync(); - trackedEntity = (IIdentifiable) _resourceFactory.CreateInstance(resourceType); - trackedEntity.StringId = id; - _dbContext.Entry(trackedEntity).State = EntityState.Unchanged; + _dbContext.Entry(entity).State = EntityState.Unchanged; + trackedEntity = entity; } return trackedEntity; @@ -314,17 +284,19 @@ private IIdentifiable GetTrackedOrNewlyAttachedEntity(Type resourceType, string /// public async Task SetRelationshipsAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { - _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); - if (parent == null) throw new ArgumentNullException(nameof(parent)); - if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); - - LoadCurrentRelationships(parent, relationship); - object trackedRelationshipValue = GetTrackedRelationshipValue(relationship, relationshipIds.ToArray()); - LoadInverseRelationships(trackedRelationshipValue, relationship); - relationship.SetValue(parent, trackedRelationshipValue, _resourceFactory); - - await _dbContext.SaveChangesAsync(); + // _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); + // if (parent == null) throw new ArgumentNullException(nameof(parent)); + // if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + // if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); + // + // LoadCurrentRelationships(parent, relationship); + // object trackedRelationshipValue = GetTrackedRelationshipValue(relationship, relationshipIds.ToArray()); + // LoadInverseRelationships(trackedRelationshipValue, relationship); + // relationship.SetValue(parent, trackedRelationshipValue, _resourceFactory); + // + // await _dbContext.SaveChangesAsync(); + + throw new NotImplementedException(); } /// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 219dc1b5d9..209d878238 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -105,14 +105,14 @@ public async Task Response422IfUpdatingNotSettableAttribute() var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); Assert.Equal("Failed to deserialize request body.", error.Title); Assert.StartsWith("Property 'TodoItem.CalculatedValue' is read-only. - Request body: <<", error.Detail); @@ -148,11 +148,10 @@ public async Task Respond_404_If_ResourceDoesNotExist() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); Assert.Equal("Resource of type 'todoItems' with ID '100' does not exist.", errorDocument.Errors[0].Detail); } @@ -177,14 +176,14 @@ public async Task Respond_422_If_IdNotInAttributeList() var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); Assert.Equal("Failed to deserialize request body: Payload must include 'id' element.", error.Title); Assert.StartsWith("Request body: <<", error.Detail); } @@ -212,14 +211,13 @@ public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Conflict, response); var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.Conflict, error.StatusCode); Assert.Equal("Resource ID mismatch between request body and endpoint URL.", error.Title); Assert.Equal($"Expected resource ID '{wrongTodoItemId}' in PATCH request body at endpoint 'http://localhost/api/v1/todoItems/{wrongTodoItemId}', instead of '{todoItem.Id}'.", error.Detail); } @@ -241,14 +239,14 @@ public async Task Respond_422_If_Broken_JSON_Payload() var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); Assert.Equal("Failed to deserialize request body.", error.Title); Assert.StartsWith("Invalid character after parsing", error.Detail); } @@ -293,7 +291,7 @@ public async Task Respond_422_If_Blocked_For_Update() Assert.Single(errorDocument.Errors); var error = errorDocument.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); Assert.Equal("Failed to deserialize request body: Changing the value of the requested attribute is not allowed.", error.Title); Assert.StartsWith("Changing the value of 'offsetDate' is not allowed. - Request body:", error.Detail); } @@ -327,7 +325,7 @@ public async Task Can_Patch_Resource() var response = await client.SendAsync(request); // Assert -- response - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.NotNull(document); @@ -369,7 +367,7 @@ public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() var response = await client.SendAsync(request); // Assert -- response - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.NotNull(document); @@ -408,7 +406,7 @@ public async Task Can_Patch_Resource_And_HasOne_Relationships() .SingleOrDefault(t => t.Id == todoItem.Id); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Equal(person.Id, updatedTodoItem.OwnerId); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 22c4555aa2..3673b20026 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -276,7 +276,7 @@ public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_R // Act var response = await client.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); Assert.Contains("For the following types, the resources with the specified ids do not exist:\\\\npeople: 900000,900001\\ntodoItems: 900002\"", responseBody); } @@ -341,7 +341,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); // we are expecting two, not three, because the request does // a "complete replace". Assert.Equal(2, updatedTodoItems.Count); @@ -419,7 +419,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); // we are expecting two, not three, because the request does // a "complete replace". Assert.Equal(2, updatedTodoItems.Count); @@ -485,7 +485,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Equal(2, updatedTodoItems.Count); } @@ -540,7 +540,7 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() .Include(t => t.Owner) .Single(t => t.Id == todoItem.Id); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Null(todoItemResult.Owner); } @@ -589,7 +589,7 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() .Include(p => p.TodoItems) .Single(p => p.Id == person.Id); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Empty(personResult.TodoItems); } @@ -755,7 +755,7 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() var response = await client.SendAsync(request); ; var responseBody = await response.Content.ReadAsStringAsync(); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); _context = _fixture.GetRequiredService(); var assertTodoItems = _context.People.Include(p => p.TodoItems) .Single(p => p.Id == person.Id).TodoItems; @@ -796,7 +796,7 @@ public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() var todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.NotNull(todoItemsOwner); } @@ -842,7 +842,7 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo .Include(t => t.Owner) .Single(t => t.Id == todoItem.Id); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Null(todoItemResult.Owner); } @@ -890,7 +890,7 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); _context = _fixture.GetRequiredService(); var assertTodoItems = _context.People.Include(p => p.TodoItems) .Single(p => p.Id == person.Id).TodoItems; @@ -941,7 +941,7 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.OK, response); _context = _fixture.GetRequiredService(); var assertTodoItems = _context.People.AsNoTracking().Include(p => p.TodoItems) .Single(p => p.Id == person.Id).TodoItems; @@ -982,11 +982,10 @@ public async Task Fails_When_Unknown_Relationship_On_Relationship_Endpoint() var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.", errorDocument.Errors[0].Detail); @@ -1020,14 +1019,18 @@ public async Task Fails_When_Missing_Resource_On_Relationship_Endpoint() var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.", errorDocument.Errors[0].Detail); } + + private void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) + { + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); + } } } From ff1246f0a89a6c7a939470ddcd56cb002e9da11f Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 11:13:02 +0200 Subject: [PATCH 022/240] chore: expose issue --- .../Services/WorkItemService.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 34 +++++-- .../Repositories/IResourceWriteRepository.cs | 3 +- .../Services/IAddRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 93 +++++++++---------- .../Spec/UpdatingRelationshipsTests.cs | 21 ++++- .../IServiceCollectionExtensionsTests.cs | 4 +- 7 files changed, 93 insertions(+), 66 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 959dbf300d..cb0f72eb5d 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -80,7 +80,7 @@ private async Task> QueryAsync(Func new NpgsqlConnection(_connectionString); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipAssignment) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 5bf0171923..95996ec24f 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -198,7 +198,7 @@ private void DetachRelationships(TResource resource) } /// - public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) + public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource, bool completeReplacementOfRelationships = true) { _traceWriter.LogMethodStart(new {requestResource, databaseResource}); if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); @@ -211,8 +211,14 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab foreach (var relationshipAttr in _targetedFields.Relationships) { - // Ensures complete replacements of relationships. - LoadCurrentRelationships(databaseResource, relationshipAttr); + // A database entity might not be tracked if it was retrieved through projection. + databaseResource = (TResource)GetTrackedOrNewlyAttachedEntity(databaseResource); + + if (completeReplacementOfRelationships) + { + // Ensures complete replacements of relationships. + LoadCurrentRelationships(databaseResource, relationshipAttr); + } ApplyRelationshipAssignment(requestResource, relationshipAttr, databaseResource); } @@ -356,12 +362,7 @@ protected void LoadCurrentRelationships(TResource databaseResource, Relationship { if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); - - // if (_dbContext.Set().Local.All(e => e.StringId != databaseResource.StringId)) - // { - // _dbContext.Entry(databaseResource).State = EntityState.Unchanged; - // } - + if (relationshipAttribute is HasManyThroughAttribute throughAttribute) { _dbContext.Entry(databaseResource).Collection(throughAttribute.ThroughProperty.Name).Load(); @@ -370,6 +371,21 @@ protected void LoadCurrentRelationships(TResource databaseResource, Relationship { _dbContext.Entry(databaseResource).Collection(hasManyAttribute.Property.Name).Load(); } + else if (relationshipAttribute is HasOneAttribute hasOneAttribute) + { + /* + * dbContext.Entry(databaseResource).State = EntityState.Detached; + * var newAttachment = _resourceFactory.CreateInstance(databaseResource.GetType()) as TResource; + * newAttachment.StringId = databaseResource.StringId; + * _dbContext.Entry(newAttachment).State = EntityState.Unchanged; + * _dbContext.Entry(newAttachment).Reload(); // <--- why doesn't the line below work without this line? + * _dbContext.Entry(newAttachment).Reference(hasOneAttribute.Property.Name).Load(); + * var relationship = hasOneAttribute.GetValue(newAttachment); // this is null, should not be null! + */ + + _dbContext.Entry(databaseResource).Reload(); // why is this line needed? see smaller repo example above + _dbContext.Entry(databaseResource).Reference(hasOneAttribute.Property.Name).Load(); + } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 3b277d49bc..66617c3eed 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -29,7 +29,8 @@ public interface IResourceWriteRepository /// /// The (partial) resource coming from the request body. /// The resource as stored in the database before the update. - Task UpdateAsync(TResource requestResource, TResource databaseResource); + /// Reflects if relationships assignments must be treated as complete replacements + Task UpdateAsync(TResource requestResource, TResource databaseResource, bool completeReplacementOfRelationships = true); /// /// Updates a relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs index 06aee75847..7936ae7f83 100644 --- a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs @@ -15,6 +15,6 @@ public interface IAddRelationshipService where TResource : cl /// /// Handles a json:api request to add resources to a to-many relationship. /// - Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues); + Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipAssignment); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index cd0c89dfb4..d7d7c562e7 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,10 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -16,7 +13,6 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore.Internal; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Services @@ -249,7 +245,7 @@ public virtual async Task CreateAsync(TResource resource) if (HasNonNullRelationshipAssignments(resource, out var assignments)) { - await AssertRelationshipValuesExistAsync(assignments); + await AssertValuesOfRelationshipAssignmentExistAsync(assignments); } await _repository.CreateAsync(resource); @@ -268,33 +264,24 @@ public virtual async Task CreateAsync(TResource resource) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues) + public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipAssignment) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships = relationshipValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - AssertRelationshipIsToMany(relationshipName); - - await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipValues)); - - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - queryLayer.Include = IncludeRelationship(_request.Relationship); - queryLayer.Filter = IncludeFilterById(id, null); - var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); - var relationshipValueCollection = ((IEnumerable) _request.Relationship.GetValue(primaryResource)).Select(i => i.StringId).ToList(); - foreach (var entry in relationshipValues) + if (relationshipAssignment != null) { - if (!relationshipValueCollection.Contains(entry.StringId)) - { - relationshipValueCollection.Add(entry.StringId); - } + await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); } - await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipValueCollection); + var requestResource = CreateRequestResource(relationshipAssignment, primaryResource); + + await _repository.UpdateAsync(requestResource, primaryResource, completeReplacementOfRelationships: false); } /// @@ -308,7 +295,7 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) { - await AssertRelationshipValuesExistAsync(assignments); + await AssertValuesOfRelationshipAssignmentExistAsync(assignments); } _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); @@ -339,22 +326,17 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour // triggered by PATCH /articles/{id}/relationships/{relationshipName} public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationshipAssignment) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipValues = relationshipAssignment}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - // Todo: add projection on primary key for both root and nested resource; that's all we need. - queryLayer.Include = IncludeRelationship(_request.Relationship); - queryLayer.Filter = IncludeFilterById(id, null); - - var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); if (relationshipAssignment != null) { - await AssertRelationshipValuesExistAsync((_request.Relationship, relationshipAssignment)); + await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); } if (_hookExecutor != null) @@ -362,18 +344,8 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); } - var requestResource = _resourceFactory.CreateInstance(); - requestResource.StringId = primaryResource.StringId; - if (_request.Relationship is HasManyAttribute) - { - var collection = (IEnumerable)relationshipAssignment; - _request.Relationship.SetValue(requestResource, TypeHelper.CopyToTypedCollection(collection, _request.Relationship.Property.PropertyType), _resourceFactory); - } - else - { - _request.Relationship.SetValue(requestResource, relationshipAssignment, _resourceFactory); - } - + var requestResource = CreateRequestResource(relationshipAssignment, primaryResource); + await _repository.UpdateAsync(requestResource, primaryResource); if (_hookExecutor != null && primaryResource != null) @@ -382,6 +354,16 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } } + private async Task GetProjectedPrimaryResourceById(TId id) + { + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + queryLayer.Filter = IncludeFilterById(id, null); + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + queryLayer.Projection = new Dictionary {{idAttribute, null}}; + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + return primaryResource; + } + /// // triggered by DELETE /articles/{id public virtual async Task DeleteAsync(TId id) @@ -493,19 +475,19 @@ private void AssertRelationshipIsToMany(string relationshipName) } } - private async Task AssertRelationshipValuesExistAsync(params (RelationshipAttribute relationship, object relationshipValue)[] assignments) + private async Task AssertValuesOfRelationshipAssignmentExistAsync(params (RelationshipAttribute relationship, object assignmentValue)[] assignments) { var nonExistingResources = new Dictionary>(); - foreach (var (relationship, relationshipValue) in assignments) + foreach (var (relationship, assignmentValue) in assignments) { - IEnumerable identifiers; - if (relationshipValue is IIdentifiable identifiable) + IEnumerable identifiers; + if (assignmentValue is IIdentifiable identifiable) { identifiers = new [] { TypeHelper.GetIdValue(identifiable) }; } else { - identifiers = ((IEnumerable) relationshipValue).Select(TypeHelper.GetIdValue).ToArray(); + identifiers = ((IEnumerable) assignmentValue).Select(TypeHelper.GetIdValue).ToArray(); } var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); @@ -527,6 +509,21 @@ private IncludeExpression IncludeRelationship(RelationshipAttribute relationship return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); } + private TResource CreateRequestResource(object relationshipAssignment, TResource primaryResource) + { + var requestResource = _resourceFactory.CreateInstance(); + requestResource.StringId = primaryResource.StringId; + + if (_request.Relationship is HasManyAttribute) + { + relationshipAssignment = TypeHelper.CopyToTypedCollection((IEnumerable) relationshipAssignment, _request.Relationship.Property.PropertyType); + } + + _request.Relationship.SetValue(requestResource, relationshipAssignment, _resourceFactory); + + return requestResource; + } + private List AsList(TResource resource) { return new List { resource }; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 3673b20026..7c6f9d41e6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -807,10 +808,23 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); todoItem.Owner = person; - - _context.People.Add(person); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); + _context.Entry(todoItem).State = EntityState.Detached; + _context.Entry(person).State = EntityState.Detached; + + var testObj = new TodoItem {Id = todoItem.Id}; + _context.Entry(testObj).State = EntityState.Unchanged; + _context.Entry(testObj).Reload(); // <--- why doesn't the line below work without this line? + await _context.Entry(testObj).Reference("Owner").LoadAsync(); + var owner = testObj.Owner; + + if (owner == null) + { + throw new Exception("shouldn't be null"); + } + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); @@ -834,8 +848,7 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo // Act var response = await client.SendAsync(request); - var responseBody = await response.Content.ReadAsStringAsync(); - + // Assert var todoItemResult = _context.TodoItems .AsNoTracking() diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 432034816c..1301b0adcb 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -168,7 +168,7 @@ private class IntResourceService : IResourceService public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); + public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipAssignment) => throw new NotImplementedException(); public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } @@ -182,7 +182,7 @@ private class GuidResourceService : IResourceService public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); - public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); + public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipAssignment) => throw new NotImplementedException(); public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); } From 4ff0e2e3aaf42c64089e368185812fe118b59a15 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 21:39:43 +0200 Subject: [PATCH 023/240] feat foreign key setting for has one relationships --- .../EntityFrameworkCoreRepository.cs | 58 ++++++++++++++----- src/JsonApiDotNetCore/TypeHelper.cs | 18 +++++- .../Spec/UpdatingRelationshipsTests.cs | 14 ----- .../SoftDeletion/SoftDeletionTests.cs | 2 +- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 95996ec24f..8e64cd3a5f 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -1,7 +1,9 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -21,6 +23,7 @@ namespace JsonApiDotNetCore.Repositories public class EntityFrameworkCoreRepository : IResourceRepository where TResource : class, IIdentifiable { + private static readonly ConcurrentDictionary _foreignKeyCache = new ConcurrentDictionary(); private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; @@ -28,7 +31,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IEnumerable _constraintProviders; private readonly IResourceAccessor _resourceAccessor; private readonly TraceLogWriter> _traceWriter; - + public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, @@ -228,13 +231,27 @@ public virtual async Task UpdateAsync(TResource requestResource, TResource datab private void ApplyRelationshipAssignment(TResource requestResource, RelationshipAttribute relationshipAttr, TResource targetResource = null) { + targetResource ??= requestResource; // Ensures the new relationship assignment will not result entities being tracked more than once. object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource); // Ensures successful handling of implicit removals of relationships. LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - relationshipAttr.SetValue(targetResource ?? requestResource, trackedRelationshipValue, _resourceFactory); + // if (re) + + var foreignKey = GetForeignKeyProperty(relationshipAttr); + if (foreignKey != null) + { + var foreignKeyValue = trackedRelationshipValue == null ? null : TypeHelper.GetTypedIdValue((IIdentifiable) trackedRelationshipValue); + foreignKey.SetValue(targetResource, foreignKeyValue); + if (_dbContext.Entry(targetResource).State != EntityState.Detached) + { + _dbContext.Entry(targetResource).State = EntityState.Modified; + } + } + + relationshipAttr.SetValue(targetResource, trackedRelationshipValue, _resourceFactory); } private object GetTrackedRelationshipValue(RelationshipAttribute relationship, object requestResource) @@ -373,20 +390,33 @@ protected void LoadCurrentRelationships(TResource databaseResource, Relationship } else if (relationshipAttribute is HasOneAttribute hasOneAttribute) { - /* - * dbContext.Entry(databaseResource).State = EntityState.Detached; - * var newAttachment = _resourceFactory.CreateInstance(databaseResource.GetType()) as TResource; - * newAttachment.StringId = databaseResource.StringId; - * _dbContext.Entry(newAttachment).State = EntityState.Unchanged; - * _dbContext.Entry(newAttachment).Reload(); // <--- why doesn't the line below work without this line? - * _dbContext.Entry(newAttachment).Reference(hasOneAttribute.Property.Name).Load(); - * var relationship = hasOneAttribute.GetValue(newAttachment); // this is null, should not be null! - */ - - _dbContext.Entry(databaseResource).Reload(); // why is this line needed? see smaller repo example above - _dbContext.Entry(databaseResource).Reference(hasOneAttribute.Property.Name).Load(); + if (GetForeignKeyProperty(hasOneAttribute) == null) + { // If the primary resource is the dependent side of a to-one relationship, there is no + // need to load the relationship because we can just set the FK. + _dbContext.Entry(databaseResource).Reference(hasOneAttribute.Property.Name).Load(); + } } } + + private PropertyInfo GetForeignKeyProperty(RelationshipAttribute relationship) + { + PropertyInfo foreignKey = null; + + if (relationship is HasOneAttribute && !_foreignKeyCache.TryGetValue(relationship, out foreignKey)) + { + var entityMetadata = _dbContext.Model.FindEntityType(typeof(TResource)); + var foreignKeyMetadata = entityMetadata.FindNavigation(relationship.Property.Name).ForeignKey; + foreignKey = foreignKeyMetadata.Properties[0].PropertyInfo; + _foreignKeyCache.TryAdd(relationship, foreignKey); + } + + if (foreignKey == null || foreignKey.DeclaringType != typeof(TResource)) + { + return null; + } + + return foreignKey; + } } /// diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 4039cf02ab..28d031a4d6 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -247,14 +247,26 @@ public static Type GetIdType(Type resourceType) } /// - /// Gets the value of the id of an identifiable. This is recommended over using `StringId` because this might + /// Gets the value of the id of an identifiable. This can be useful to use over `StringId` because this might /// fail when the model has obfuscated IDs. /// public static string GetIdValue(IIdentifiable identifiable) { if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); - - return identifiable.GetType().GetProperty(nameof(Identifiable.Id)).GetValue(identifiable)?.ToString(); + var typedId = GetTypedIdValue(identifiable); + + return typedId.ToString(); + } + + /// + /// Gets the typed value of the id of an identifiable. + /// + public static object GetTypedIdValue(IIdentifiable identifiable) + { + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + var typedId = identifiable.GetType().GetProperty(nameof(Identifiable.Id)).GetValue(identifiable); + + return typedId; } public static object CreateInstance(Type type) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 7c6f9d41e6..4477c8fcd0 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -811,21 +811,7 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); - _context.Entry(todoItem).State = EntityState.Detached; - _context.Entry(person).State = EntityState.Detached; - var testObj = new TodoItem {Id = todoItem.Id}; - _context.Entry(testObj).State = EntityState.Unchanged; - _context.Entry(testObj).Reload(); // <--- why doesn't the line below work without this line? - await _context.Entry(testObj).Reference("Owner").LoadAsync(); - var owner = testObj.Owner; - - if (owner == null) - { - throw new Exception("shouldn't be null"); - } - - var builder = WebHost.CreateDefaultBuilder() .UseStartup(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 8e4e52e5b4..ad9014c213 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -395,7 +395,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_relationship_for_deleted_parent() + public async Task Cannot_set_relationship_for_deleted_parent() { // Arrange var company = new Company From 3b824164b5959e6b0b7e99fba649dd41c4c7d58d Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 23:51:26 +0200 Subject: [PATCH 024/240] feat: implemented post and delete to-many by using refactored code of SetRelationshipAsync in service layer --- .../Repositories/DbContextARepository.cs | 4 +- .../Repositories/DbContextBRepository.cs | 4 +- .../Services/WorkItemService.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 24 +------ .../Repositories/IResourceWriteRepository.cs | 9 +-- .../Repositories/ResourceAccessor.cs | 62 ++++++++++------ .../Services/IDeleteRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 70 +++++++++---------- .../ServiceDiscoveryFacadeTests.cs | 4 +- .../EntityFrameworkCoreRepositoryTests.cs | 3 +- .../ResultCapturingRepository.cs | 3 +- .../BaseJsonApiController_Tests.cs | 12 ++-- .../IServiceCollectionExtensionsTests.cs | 5 +- .../Models/ResourceConstructionTests.cs | 14 ++-- .../ResourceHooks/ResourceHooksTestsSetup.cs | 3 +- .../Server/RequestDeserializerTests.cs | 4 +- .../Services/DefaultResourceService_Tests.cs | 2 +- 17 files changed, 111 insertions(+), 116 deletions(-) diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index fa6bb24700..6fcbe4aa59 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -13,9 +13,9 @@ public class DbContextARepository : EntityFrameworkCoreRepository contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, resourceAccessor, loggerFactory) + constraintProviders, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index 6ceeaf6ffe..6fb61e83bc 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -13,9 +13,9 @@ public class DbContextBRepository : EntityFrameworkCoreRepository contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, resourceAccessor, loggerFactory) + constraintProviders, loggerFactory) { } } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index cb0f72eb5d..70c100c414 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -85,7 +85,7 @@ public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) + public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable removals) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 8e64cd3a5f..fb89eee064 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -29,7 +29,6 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; - private readonly IResourceAccessor _resourceAccessor; private readonly TraceLogWriter> _traceWriter; public EntityFrameworkCoreRepository( @@ -38,7 +37,6 @@ public EntityFrameworkCoreRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) { if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); @@ -48,7 +46,6 @@ public EntityFrameworkCoreRepository( _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); - _resourceAccessor = resourceAccessor ?? throw new ArgumentNullException(nameof(constraintProviders)); _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -304,24 +301,6 @@ private IIdentifiable GetTrackedOrNewlyAttachedEntity(IIdentifiable entity) return trackedEntity; } - /// - public async Task SetRelationshipsAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - // _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); - // if (parent == null) throw new ArgumentNullException(nameof(parent)); - // if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - // if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); - // - // LoadCurrentRelationships(parent, relationship); - // object trackedRelationshipValue = GetTrackedRelationshipValue(relationship, relationshipIds.ToArray()); - // LoadInverseRelationships(trackedRelationshipValue, relationship); - // relationship.SetValue(parent, trackedRelationshipValue, _resourceFactory); - // - // await _dbContext.SaveChangesAsync(); - - throw new NotImplementedException(); - } - /// public virtual async Task DeleteAsync(TId id) { @@ -431,9 +410,8 @@ public EntityFrameworkCoreRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 66617c3eed..5f3dfaa1e2 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -29,14 +29,9 @@ public interface IResourceWriteRepository /// /// The (partial) resource coming from the request body. /// The resource as stored in the database before the update. - /// Reflects if relationships assignments must be treated as complete replacements + /// Enable complete replacements when dealing with relationship assignments. Task UpdateAsync(TResource requestResource, TResource databaseResource, bool completeReplacementOfRelationships = true); - - /// - /// Updates a relationship in the underlying data store. - /// - Task SetRelationshipsAsync(TResource parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); - + /// /// Deletes a resource from the underlying data store. /// diff --git a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs index 6c2ae35476..4545d41a41 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Repositories @@ -19,34 +20,25 @@ public class ResourceAccessor : IResourceAccessor static ResourceAccessor() { - _accessorMethod = typeof(ResourceAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Static); - } - - private static async Task> GetById( - IEnumerable ids, - IResourceReadRepository repository, - ResourceContext resourceContext) - where TResource : class, IIdentifiable - { - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - - var queryLayer = new QueryLayer(resourceContext) - { - Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), - ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) - }; - - return await repository.GetAsync(queryLayer); + _accessorMethod = typeof(ResourceAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); } private readonly IServiceProvider _serviceProvider; private readonly IResourceContextProvider _provider; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly Dictionary _parameterizedMethodRepositoryCache = new Dictionary(); - public ResourceAccessor(IServiceProvider serviceProvider, IResourceContextProvider provider) + public ResourceAccessor( + IServiceProvider serviceProvider, + IResourceContextProvider provider, + IQueryLayerComposer composer, + IResourceDefinitionAccessor resourceDefinitionAccessor) { _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); + _queryLayerComposer = composer ?? throw new ArgumentException(nameof(composer)); + _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentException(nameof(resourceDefinitionAccessor)); } /// @@ -55,7 +47,7 @@ public async Task> GetResourcesByIdAsync(Type resourc var resourceContext = _provider.GetResourceContext(resourceType); var (parameterizedMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); - var resources = await parameterizedMethod.InvokeAsync(null, ids, repository, resourceContext); + var resources = await parameterizedMethod.InvokeAsync(this, ids, repository, resourceContext); return (IEnumerable)resources; } @@ -74,5 +66,35 @@ public async Task> GetResourcesByIdAsync(Type resourc return accessorPair; } + + private async Task> GetById( + IEnumerable ids, + IResourceReadRepository repository, + ResourceContext resourceContext) + where TResource : class, IIdentifiable + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + + var equalsAnyOfFilter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), + ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()); + var filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, equalsAnyOfFilter); + + var projection = new Dictionary { { idAttribute, null } }; + + var queryLayer = new QueryLayer(resourceContext) + { + Filter = filter, + }; + + // Only apply projection when there is no resource inheritance. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844. + // We can leave it out because the projection here is an optimization, not a functional requirement. + if (!resourceContext.ResourceType.GetTypeInfo().IsAbstract) + { + queryLayer.Projection = projection; + } + + return await repository.GetAsync(queryLayer); + } } } + diff --git a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs index c3edc64167..0abbf3e0a2 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs @@ -14,6 +14,6 @@ public interface IDeleteRelationshipService where TResource : /// /// Handles a json:api request to remove resources from a to-many relationship. /// - Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues); + Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable removals); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index d7d7c562e7..973130c1fe 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -251,8 +251,7 @@ public virtual async Task CreateAsync(TResource resource) await _repository.CreateAsync(resource); resource = await GetPrimaryResourceById(resource.Id, true); - - + if (_hookExecutor != null) { _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); @@ -266,6 +265,7 @@ public virtual async Task CreateAsync(TResource resource) // triggered by POST /articles/{id}/relationships/{relationshipName} public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipAssignment) { + relationshipAssignment = relationshipAssignment.ToArray(); _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); @@ -274,14 +274,13 @@ public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumera var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); - if (relationshipAssignment != null) + if (relationshipAssignment.Any()) { await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); - } - var requestResource = CreateRequestResource(relationshipAssignment, primaryResource); - - await _repository.UpdateAsync(requestResource, primaryResource, completeReplacementOfRelationships: false); + var requestResource = CreateRequestResource(relationshipAssignment, primaryResource); + await _repository.UpdateAsync(requestResource, primaryResource, completeReplacementOfRelationships: false); + } } /// @@ -354,13 +353,28 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } } - private async Task GetProjectedPrimaryResourceById(TId id) + private async Task GetProjectedPrimaryResourceById(TId id, bool includeRequestRelationship = false) { var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - queryLayer.Filter = IncludeFilterById(id, null); + + queryLayer.Filter = IncludeFilterById(id, queryLayer.Filter); + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); queryLayer.Projection = new Dictionary {{idAttribute, null}}; + + if (includeRequestRelationship) + { + queryLayer.Include = IncludeRelationshipExpression(_request.Relationship); + var relationshipResourceContext = _provider.GetResourceContext(_request.Relationship.RightType); + var relationshipIdAttribute = relationshipResourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var secondaryLayer = new QueryLayer(relationshipResourceContext) + { + Projection = new Dictionary {{relationshipIdAttribute, null}} + }; + queryLayer.Projection.Add(_request.Relationship, secondaryLayer); + } var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + return primaryResource; } @@ -396,40 +410,24 @@ public virtual async Task DeleteAsync(TId id) /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipValues) + public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable removals) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, removals}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - AssertRelationshipIsToMany(relationshipName); - - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - queryLayer.Include = IncludeRelationship(_request.Relationship); - queryLayer.Filter = IncludeFilterById(id, null); - /* - * We are fetching resources plus related - * in most ideal scenario - * one to many: clear FK - * many to many: clear join table record - * no resources need to be fetched. - * implicit removes: don't exist, because we're explicitly removing - * complete replacement: not what we're doing. - */ - var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + var primaryResource = await GetProjectedPrimaryResourceById(id, includeRequestRelationship: true); AssertPrimaryResourceExists(primaryResource); - - var relationshipValueCollection = ((IEnumerable) _request.Relationship.GetValue(primaryResource)).Select(TypeHelper.GetIdValue).ToList(); - foreach (var entry in relationshipValues) + + var currentAssignment = ((IEnumerable)_request.Relationship.GetValue(primaryResource)).ToArray(); + var newAssignment = currentAssignment.Where(i => removals.All(r => r.StringId != i.StringId)).ToArray(); + + if (newAssignment.Length < currentAssignment.Length) { - if (relationshipValueCollection.Contains(entry.StringId)) - { - relationshipValueCollection.Remove(entry.StringId); - } + var requestResource = CreateRequestResource(newAssignment, primaryResource); + await _repository.UpdateAsync(requestResource, primaryResource, completeReplacementOfRelationships: true); } - - await _repository.SetRelationshipsAsync(primaryResource, _request.Relationship, relationshipValueCollection); } private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) @@ -504,7 +502,7 @@ private async Task AssertValuesOfRelationshipAssignmentExistAsync(params (Relati } } - private IncludeExpression IncludeRelationship(RelationshipAttribute relationship) + private IncludeExpression IncludeRelationshipExpression(RelationshipAttribute relationship) { return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 3f5757a088..a293d2efc6 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -39,6 +39,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -171,9 +172,8 @@ public TestModelRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, resourceAccessor, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 1c49416027..41e6d03508 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -88,8 +88,7 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri contextResolverMock.Setup(m => m.GetContext()).Returns(context); var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); var targetedFields = new Mock(); - var resourceAccessor = new Mock().Object; - var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, resourceFactory, new List(), resourceAccessor, NullLoggerFactory.Instance); + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, resourceFactory, new List(), NullLoggerFactory.Instance); return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index 5277eab0c0..42737296e7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -22,11 +22,10 @@ public ResultCapturingRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IResourceAccessor resourceAccessor, ILoggerFactory loggerFactory, ResourceCaptureStore captureStore) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, resourceAccessor, loggerFactory) + constraintProviders, loggerFactory) { _captureStore = captureStore; } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index fe2311c6a2..19802862ab 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -35,18 +35,18 @@ public ResourceController( public ResourceController( IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create = null, IGetAllService getAll = null, IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IUpdateService update = null, - IDeleteService delete = null, - IAddRelationshipService addRelationship = null, IGetRelationshipService getRelationship = null, + ICreateService create = null, + IAddRelationshipService addRelationship = null, + IUpdateService update = null, ISetRelationshipService setRelationship = null, + IDeleteService delete = null, IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, create, getAll, getById, getSecondary, update, delete, addRelationship, - getRelationship, setRelationship, deleteRelationship) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, + update, setRelationship, delete, deleteRelationship) { } } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 1301b0adcb..565e849ea7 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -54,7 +54,6 @@ public void AddJsonApiInternals_Adds_All_Required_Services() Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); - Assert.NotNull(provider.GetService(typeof(RepositoryRelationshipUpdateHelper))); } [Fact] @@ -169,7 +168,7 @@ private class IntResourceService : IResourceService public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipAssignment) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable removals) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -183,7 +182,7 @@ private class GuidResourceService : IResourceService public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipAssignment) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipValues) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable removals) => throw new NotImplementedException(); } diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index fb0cacc5b5..6fca6fc3ec 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.Design; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCoreExample.Data; @@ -15,12 +16,15 @@ namespace UnitTests.Models { public sealed class ResourceConstructionTests { + public Mock _requestMock; public Mock _mockHttpContextAccessor; - + public ResourceConstructionTests() { _mockHttpContextAccessor = new Mock(); _mockHttpContextAccessor.Setup(mock => mock.HttpContext).Returns(new DefaultHttpContext()); + _requestMock = new Mock(); + _requestMock.Setup(mock => mock.Kind).Returns(EndpointKind.Primary); } [Fact] @@ -31,7 +35,7 @@ public void When_resource_has_default_constructor_it_must_succeed() .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { @@ -60,7 +64,7 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { @@ -96,7 +100,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ var serviceContainer = new ServiceContainer(); serviceContainer.AddService(typeof(AppDbContext), appDbContext); - var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { @@ -126,7 +130,7 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 587500efd8..03dc011bf0 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -372,9 +372,8 @@ private IResourceReadRepository CreateTestRepository(AppDbC var serviceProvider = ((IInfrastructure) dbContext).Instance; var resourceFactory = new ResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); - var resourceAccessor = new Mock().Object; var targetedFields = new TargetedFields(); - return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, resourceFactory, new List(), resourceAccessor, NullLoggerFactory.Instance); + return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, resourceFactory, new List(), NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index c01f2f9b7e..ead59ed1f5 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel.Design; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization; @@ -14,9 +15,10 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup { private readonly RequestDeserializer _deserializer; private readonly Mock _fieldsManagerMock = new Mock(); + private readonly Mock _requestMock = new Mock(); public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object); + _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object, _requestMock.Object); } [Fact] diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index c307c05f8f..48d6a8275f 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -76,7 +76,7 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); - var resourceAccessor = new Mock().Object; + var resourceAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; From f68e82ebc03e3da7d5f3ba03f9443abe5e36edb3 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 6 Oct 2020 23:57:18 +0200 Subject: [PATCH 025/240] chore: self review --- src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs | 1 - src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs | 2 +- src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index b33a1f0b97..d8b6304d7b 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 1091e21815..197a295f69 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -25,7 +25,7 @@ internal static TEntity GetTrackedEntity(this DbContext context, TEntit entry.Entity.GetType() == entityType && ((IIdentifiable) entry.Entity).StringId == entity.StringId); - return (TEntity)entityEntry?.Entity; + return (TEntity) entityEntry?.Entity; } /// diff --git a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs index f391459f04..c025ed97ea 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Repositories public interface IResourceAccessor { /// - /// Gets resources by id. Any id that is not matched is returned as null. + /// Gets resources by id. /// /// The type for which to create a repository. /// The ids to filter on. From 7c84aedc3660adde6001a2b63d6bbd2fd056dce6 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 7 Oct 2020 22:57:05 +0200 Subject: [PATCH 026/240] feat: implemented new pipeline elements for the repository, performed some refactoring and processed review feedback --- .../Controllers/BaseJsonApiController.cs | 23 +- .../Controllers/JsonApiCommandController.cs | 12 +- .../Controllers/JsonApiController.cs | 12 +- .../Errors/QueryExecutionException.cs | 16 + .../RelationshipUpdateForbiddenException.cs | 18 + .../RequestMethodNotAllowedException.cs | 7 - .../Repositories/DbContextExtensions.cs | 17 + .../EntityFrameworkCoreRepository.cs | 363 ++++++++++-------- .../Repositories/IResourceAccessor.cs | 2 +- .../Repositories/IResourceWriteRepository.cs | 22 +- .../Repositories/ResourceAccessor.cs | 52 +-- .../Resources/IResourceFactory.cs | 2 +- .../Resources/ResourceFactory.cs | 11 +- .../Serialization/RequestDeserializer.cs | 2 +- .../Services/IAddRelationshipService.cs | 2 +- .../Services/IDeleteRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 244 ++++++------ src/JsonApiDotNetCore/TypeHelper.cs | 48 +-- .../ResourceDefinitionTests.cs | 20 +- .../Spec/FetchingRelationshipsTests.cs | 10 +- 20 files changed, 515 insertions(+), 370 deletions(-) create mode 100644 src/JsonApiDotNetCore/Errors/QueryExecutionException.cs create mode 100644 src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 25219b3a9f..237cf4972a 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -97,6 +97,7 @@ public virtual async Task GetAsync() if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var resources = await _getAll.GetAsync(); + return Ok(resources); } @@ -110,6 +111,7 @@ public virtual async Task GetAsync(TId id) if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var resource = await _getById.GetAsync(id); + return Ok(resource); } @@ -126,6 +128,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); + return Ok(relationship); } @@ -174,13 +177,14 @@ public virtual async Task PostAsync([FromBody] TResource resource /// /// Adds resources to a to-many relationship. /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection relationshipAssignment) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_addRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _addRelationship.AddRelationshipAsync(id, relationshipName, relationships); + await _addRelationship.AddRelationshipAsync(id, relationshipName, relationshipAssignment); + return Ok(); } @@ -202,19 +206,20 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } var updated = await _update.UpdateAsync(id, resource); + return updated == null ? Ok(null) : Ok(updated); } /// /// Updates a relationship. /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationshipAssignment) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _setRelationship.SetRelationshipAsync(id, relationshipName, relationships); + await _setRelationship.SetRelationshipAsync(id, relationshipName, relationshipAssignment); return Ok(); } @@ -235,13 +240,13 @@ public virtual async Task DeleteAsync(TId id) /// /// Removes resources from a to-many relationship. /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removals) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, removals}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_deleteRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, relationships); + await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, removals); return Ok(); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 4d3957231e..ea576a752c 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -33,8 +33,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.PostRelationshipAsync(id, relationshipName, relationships); + TId id, string relationshipName, [FromBody] IReadOnlyCollection relationshipAssignment) + => await base.PostRelationshipAsync(id, relationshipName, relationshipAssignment); /// [HttpPatch("{id}")] @@ -44,8 +44,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + TId id, string relationshipName, [FromBody] object relationshipAssignment) + => await base.PatchRelationshipAsync(id, relationshipName, relationshipAssignment); /// [HttpDelete("{id}")] @@ -53,8 +53,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.DeleteRelationshipAsync(id, relationshipName, relationships); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removals) + => await base.DeleteRelationshipAsync(id, relationshipName, removals); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 9783eb33d0..07600ffe92 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -69,8 +69,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.PostRelationshipAsync(id, relationshipName, relationships); + TId id, string relationshipName, [FromBody] IReadOnlyCollection relationshipAssignment) + => await base.PostRelationshipAsync(id, relationshipName, relationshipAssignment); /// [HttpPatch("{id}")] @@ -82,8 +82,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + TId id, string relationshipName, [FromBody] object relationshipAssignment) + => await base.PatchRelationshipAsync(id, relationshipName, relationshipAssignment); /// [HttpDelete("{id}")] @@ -91,8 +91,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IEnumerable relationships) - => await base.DeleteRelationshipAsync(id, relationshipName, relationships); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removals) + => await base.DeleteRelationshipAsync(id, relationshipName, removals); } /// diff --git a/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs b/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs new file mode 100644 index 0000000000..29f78bc73b --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs @@ -0,0 +1,16 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown Entity Framework Core fails executing a query. + /// + public sealed class QueryExecutionException : Exception + { + public QueryExecutionException(Exception exception) : base(exception.Message, exception) { } + } +} diff --git a/src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs b/src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs new file mode 100644 index 0000000000..5b826d1d30 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs @@ -0,0 +1,18 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when a request is received that contains an unsupported HTTP verb. + /// + public sealed class RelationshipUpdateForbiddenException : JsonApiException + { + public RelationshipUpdateForbiddenException(string toOneRelationship) + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "The request to update the relationship is forbidden.", + Detail = $"Relationship {toOneRelationship} is not a to-many relationship." + }) { } + } +} diff --git a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs index 1d65132f3e..7444d6cc46 100644 --- a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs @@ -20,12 +20,5 @@ public RequestMethodNotAllowedException(HttpMethod method) { Method = method; } - - public RequestMethodNotAllowedException(string toOneRelationship) - : base(new Error(HttpStatusCode.MethodNotAllowed) - { - Title = "The request method is not allowed.", - Detail = $"Relationship {toOneRelationship} is not a to-many relationship." - }) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 197a295f69..2e5a3c32dd 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -28,6 +28,23 @@ internal static TEntity GetTrackedEntity(this DbContext context, TEntit return (TEntity) entityEntry?.Entity; } + internal static object GetTrackedOrAttachCurrent(this DbContext context, IIdentifiable entity) + { + var trackedEntity = context.GetTrackedEntity(entity); + if (trackedEntity == null) + { + context.Entry(entity).State = EntityState.Unchanged; + trackedEntity = entity; + } + + return trackedEntity; + } + + internal static TResource GetTrackedOrAttachCurrent(this DbContext context, TResource entity) where TResource : IIdentifiable + { + return (TResource)GetTrackedOrAttachCurrent(context, (IIdentifiable)entity); + } + /// /// Gets the current transaction or creates a new one. /// If a transaction already exists, commit, rollback and dispose diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index fb89eee064..0d82d004c1 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -23,6 +24,7 @@ namespace JsonApiDotNetCore.Repositories public class EntityFrameworkCoreRepository : IResourceRepository where TResource : class, IIdentifiable { + // ReSharper disable once StaticMemberInGenericType private static readonly ConcurrentDictionary _foreignKeyCache = new ConcurrentDictionary(); private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; @@ -57,6 +59,7 @@ public virtual async Task> GetAsync(QueryLayer la if (layer == null) throw new ArgumentNullException(nameof(layer)); IQueryable query = ApplyQueryLayer(layer); + return await query.ToListAsync(); } @@ -112,9 +115,10 @@ public virtual async Task CreateAsync(TResource resource) _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - foreach (var relationshipAttr in _targetedFields.Relationships) + foreach (var relationship in _targetedFields.Relationships) { - ApplyRelationshipAssignment(resource, relationshipAttr); + var relationshipAssignment = relationship.GetValue(resource); + ApplyRelationshipAssignment(relationshipAssignment, relationship, resource); } _dbContext.Set().Add(resource); @@ -127,6 +131,135 @@ public virtual async Task CreateAsync(TResource resource) DetachRelationships(resource); } + public async Task AddRelationshipAsync(TId id, IReadOnlyCollection relationshipAssignment) + { + _traceWriter.LogMethodStart(new {id, relationshipAssignment}); + if (relationshipAssignment == null) throw new ArgumentNullException(nameof(relationshipAssignment)); + + var relationship = _targetedFields.Relationships.Single(); + var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + + ApplyRelationshipAssignment(relationshipAssignment, relationship, databaseResource); + + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new QueryExecutionException(exception); + } + } + + public async Task SetRelationshipAsync(TId id, object relationshipAssignment) + { + _traceWriter.LogMethodStart(new {id, relationshipAssignment}); + + var relationship = _targetedFields.Relationships.Single(); + var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + + LoadCurrentRelationships(databaseResource, relationship); + + ApplyRelationshipAssignment(relationshipAssignment, relationship, databaseResource); + + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new QueryExecutionException(exception); + } + } + + /// + public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) + { + _traceWriter.LogMethodStart(new {requestResource, databaseResource}); + if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); + + foreach (var attribute in _targetedFields.Attributes) + { + attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + } + + foreach (var relationship in _targetedFields.Relationships) + { + // A database entity might not be tracked if it was retrieved through projection. + databaseResource = _dbContext.GetTrackedOrAttachCurrent(databaseResource); + + // Ensures complete replacements of relationships. + LoadCurrentRelationships(databaseResource, relationship); + + var relationshipAssignment = relationship.GetValue(requestResource); + ApplyRelationshipAssignment(relationshipAssignment, relationship, databaseResource); + } + + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new QueryExecutionException(exception); + } + } + + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + var resource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + _dbContext.Remove(resource); + + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new QueryExecutionException(exception); + } + } + + public async Task DeleteRelationshipAsync(TId id, IReadOnlyCollection removals) + { + _traceWriter.LogMethodStart(new {id, removals}); + if (removals == null) throw new ArgumentNullException(nameof(removals)); + + var relationship = _targetedFields.Relationships.Single(); + var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + + LoadCurrentRelationships(databaseResource, relationship); + + var currentAssignment = ((IReadOnlyCollection) relationship.GetValue(databaseResource)); + var newAssignment = currentAssignment.Where(i => removals.All(r => r.StringId != i.StringId)).ToArray(); + + if (newAssignment.Length < currentAssignment.Count()) + { + ApplyRelationshipAssignment(newAssignment, relationship, databaseResource); + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new QueryExecutionException(exception); + } + } + } + + /// + public virtual void FlushFromCache(TResource resource) + { + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + _dbContext.Entry(resource).State = EntityState.Detached; + } + /// /// Loads the inverse relationships to prevent foreign key constraints from being violated /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. @@ -139,37 +272,51 @@ public virtual async Task CreateAsync(TResource resource) /// this into account. /// /// - private void LoadInverseRelationships(object trackedRelationshipValue, RelationshipAttribute relationshipAttr) + private void LoadInverseRelationships(object trackedRelationshipAssignment, RelationshipAttribute relationship) { - if (relationshipAttr.InverseNavigation == null || trackedRelationshipValue == null) return; - if (relationshipAttr is HasOneAttribute hasOneAttr) - { - var relationEntry = _dbContext.Entry((IIdentifiable)trackedRelationshipValue); - if (IsHasOneRelationship(hasOneAttr.InverseNavigation, trackedRelationshipValue.GetType())) - relationEntry.Reference(hasOneAttr.InverseNavigation).Load(); - else - relationEntry.Collection(hasOneAttr.InverseNavigation).Load(); - } - else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) + var inverseNavigation = relationship.InverseNavigation; + if (inverseNavigation != null && trackedRelationshipAssignment != null) { - foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) - _dbContext.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); + if (trackedRelationshipAssignment is IIdentifiable hasOneAssignment) + { + var hasOneAssignmentEntry = _dbContext.Entry(hasOneAssignment); + if (IsOneToOne((HasOneAttribute)relationship)) + { + hasOneAssignmentEntry.Reference(inverseNavigation).Load(); + } + else + { + hasOneAssignmentEntry.Collection(inverseNavigation).Load(); + } + } + else if (!(relationship is HasManyThroughAttribute)) + { + foreach (IIdentifiable assignmentElement in (IEnumerable) trackedRelationshipAssignment) + { + _dbContext.Entry(assignmentElement).Reference(inverseNavigation).Load(); + } + } } } - private bool IsHasOneRelationship(string internalRelationshipName, Type type) + // private bool IsOneToOne(string propertyName, Type type) + private bool IsOneToOne(HasOneAttribute relationship) { - var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.Property.Name == internalRelationshipName); - if (relationshipAttr != null) + var relationshipType = relationship.RightType; + var inverseNavigation = relationship.InverseNavigation; + + var inverseRelationship = _resourceGraph.GetRelationships(relationshipType).FirstOrDefault(r => r.Property.Name == inverseNavigation); + if (inverseRelationship != null) { - if (relationshipAttr is HasOneAttribute) - return true; - - return false; + return inverseRelationship is HasOneAttribute; } + // relationshipAttr is null when we don't put a [RelationshipAttribute] on the inverse navigation property. - // In this case we use reflection to figure out what kind of relationship is pointing back. - return !TypeHelper.IsOrImplementsInterface(type.GetProperty(internalRelationshipName).PropertyType, typeof(IEnumerable)); + // In this case we reflect on the type to figure out what kind of relationship is pointing back. + var inverseProperty = relationshipType.GetProperty(inverseNavigation).PropertyType; + var inversePropertyIsEnumerable = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); + + return !inversePropertyIsEnumerable; } private void DetachRelationships(TResource resource) @@ -197,50 +344,54 @@ private void DetachRelationships(TResource resource) } } - /// - public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource, bool completeReplacementOfRelationships = true) + /// + /// Before assigning new relationship values (UpdateAsync), we need to + /// attach the current database values of the relationship to the dbContext, else + /// it will not perform a complete-replace which is required for + /// one-to-many and many-to-many. + /// + /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. + /// If we want to update this todo-item set to `t3` and `t4`, simply assigning + /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, + /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, + /// after which the reassignment `p1.todoItems = [t3, t4]` will actually + /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. + /// + protected void LoadCurrentRelationships(TResource databaseResource, RelationshipAttribute relationshipAttribute) { - _traceWriter.LogMethodStart(new {requestResource, databaseResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); - - foreach (var attribute in _targetedFields.Attributes) + if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); + + if (relationshipAttribute is HasManyThroughAttribute throughAttribute) { - attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + _dbContext.Entry(databaseResource).Collection(throughAttribute.ThroughProperty.Name).Load(); } - - foreach (var relationshipAttr in _targetedFields.Relationships) + else if (relationshipAttribute is HasManyAttribute hasManyAttribute) { - // A database entity might not be tracked if it was retrieved through projection. - databaseResource = (TResource)GetTrackedOrNewlyAttachedEntity(databaseResource); - - if (completeReplacementOfRelationships) - { - // Ensures complete replacements of relationships. - LoadCurrentRelationships(databaseResource, relationshipAttr); + _dbContext.Entry(databaseResource).Collection(hasManyAttribute.Property.Name).Load(); + } + else if (relationshipAttribute is HasOneAttribute hasOneAttribute) + { + if (GetForeignKeyProperty(hasOneAttribute) == null) + { // If the primary resource is the dependent side of a to-one relationship, there is no + // need to load the relationship because we can just set the FK. + _dbContext.Entry(databaseResource).Reference(hasOneAttribute.Property.Name).Load(); } - - ApplyRelationshipAssignment(requestResource, relationshipAttr, databaseResource); } - - await _dbContext.SaveChangesAsync(); } - private void ApplyRelationshipAssignment(TResource requestResource, RelationshipAttribute relationshipAttr, TResource targetResource = null) + private void ApplyRelationshipAssignment(object relationshipAssignment, RelationshipAttribute relationship, TResource targetResource = null) { - targetResource ??= requestResource; // Ensures the new relationship assignment will not result entities being tracked more than once. - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource); + var trackedRelationshipAssignment = GetTrackedRelationshipValue(relationship, relationshipAssignment); // Ensures successful handling of implicit removals of relationships. - LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - - // if (re) + LoadInverseRelationships(trackedRelationshipAssignment, relationship); - var foreignKey = GetForeignKeyProperty(relationshipAttr); + var foreignKey = GetForeignKeyProperty(relationship); if (foreignKey != null) { - var foreignKeyValue = trackedRelationshipValue == null ? null : TypeHelper.GetTypedIdValue((IIdentifiable) trackedRelationshipValue); + var foreignKeyValue = trackedRelationshipAssignment == null ? null : TypeHelper.GetResourceTypedId((IIdentifiable) trackedRelationshipAssignment); foreignKey.SetValue(targetResource, foreignKeyValue); if (_dbContext.Entry(targetResource).State != EntityState.Detached) { @@ -248,12 +399,11 @@ private void ApplyRelationshipAssignment(TResource requestResource, Relationship } } - relationshipAttr.SetValue(targetResource, trackedRelationshipValue, _resourceFactory); + relationship.SetValue(targetResource, trackedRelationshipAssignment, _resourceFactory); } - private object GetTrackedRelationshipValue(RelationshipAttribute relationship, object requestResource) + private object GetTrackedRelationshipValue(RelationshipAttribute relationship, object relationshipAssignment) { - object relationshipAssignment = relationship.GetValue(requestResource); object trackedRelationshipAssignment; if (relationshipAssignment == null) @@ -262,16 +412,16 @@ private object GetTrackedRelationshipValue(RelationshipAttribute relationship, o } else if (relationshipAssignment is IIdentifiable hasOneAssignment) { - trackedRelationshipAssignment = GetTrackedOrNewlyAttachedEntity(hasOneAssignment); + trackedRelationshipAssignment = _dbContext.GetTrackedOrAttachCurrent(hasOneAssignment); } else { - var hasManyAssignment = ((IEnumerable)relationshipAssignment).ToArray(); - var collection = new object[hasManyAssignment.Length]; + var hasManyAssignment = ((IReadOnlyCollection) relationshipAssignment); + var collection = new object[hasManyAssignment.Count()]; - for (int i = 0; i < hasManyAssignment.Length; i++) + for (int i = 0; i < hasManyAssignment.Count; i++) { - var trackedHasManyElement = GetTrackedOrNewlyAttachedEntity(hasManyAssignment[i]); + var trackedHasManyElement = _dbContext.GetTrackedOrAttachCurrent(hasManyAssignment.ElementAt(i)); // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. var conversionTarget = trackedHasManyElement.GetType(); @@ -284,99 +434,6 @@ private object GetTrackedRelationshipValue(RelationshipAttribute relationship, o return trackedRelationshipAssignment; } - private IIdentifiable GetTrackedOrNewlyAttachedEntity(IIdentifiable entity) - { - var trackedEntity = _dbContext.GetTrackedEntity(entity); - if (trackedEntity == null) - { - // the relationship pointer is new to EF Core, but we are sure - // it exists in the database, so we attach it. In this case, as per - // the json:api spec, we can also safely assume that no fields of - // this resource were updated. Note that if it was already tracked, reattaching it - // will throw an error when calling dbContext.SaveAsync(); - _dbContext.Entry(entity).State = EntityState.Unchanged; - trackedEntity = entity; - } - - return trackedEntity; - } - - /// - public virtual async Task DeleteAsync(TId id) - { - _traceWriter.LogMethodStart(new {id}); - - var resourceToDelete = _resourceFactory.CreateInstance(); - resourceToDelete.Id = id; - - var resourceFromCache = _dbContext.GetTrackedEntity(resourceToDelete); - if (resourceFromCache != null) - { - resourceToDelete = resourceFromCache; - } - else - { - _dbContext.Attach(resourceToDelete); - } - - _dbContext.Remove(resourceToDelete); - - try - { - await _dbContext.SaveChangesAsync(); - return true; - } - catch (DbUpdateConcurrencyException) - { - return false; - } - } - - /// - public virtual void FlushFromCache(TResource resource) - { - _traceWriter.LogMethodStart(new {resource}); - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - _dbContext.Entry(resource).State = EntityState.Detached; - } - - /// - /// Before assigning new relationship values (UpdateAsync), we need to - /// attach the current database values of the relationship to the dbContext, else - /// it will not perform a complete-replace which is required for - /// one-to-many and many-to-many. - /// - /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. - /// If we want to update this todo-item set to `t3` and `t4`, simply assigning - /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, - /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, - /// after which the reassignment `p1.todoItems = [t3, t4]` will actually - /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. - /// - protected void LoadCurrentRelationships(TResource databaseResource, RelationshipAttribute relationshipAttribute) - { - if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); - if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); - - if (relationshipAttribute is HasManyThroughAttribute throughAttribute) - { - _dbContext.Entry(databaseResource).Collection(throughAttribute.ThroughProperty.Name).Load(); - } - else if (relationshipAttribute is HasManyAttribute hasManyAttribute) - { - _dbContext.Entry(databaseResource).Collection(hasManyAttribute.Property.Name).Load(); - } - else if (relationshipAttribute is HasOneAttribute hasOneAttribute) - { - if (GetForeignKeyProperty(hasOneAttribute) == null) - { // If the primary resource is the dependent side of a to-one relationship, there is no - // need to load the relationship because we can just set the FK. - _dbContext.Entry(databaseResource).Reference(hasOneAttribute.Property.Name).Load(); - } - } - } - private PropertyInfo GetForeignKeyProperty(RelationshipAttribute relationship) { PropertyInfo foreignKey = null; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs index c025ed97ea..d1acd85d8e 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs @@ -17,6 +17,6 @@ public interface IResourceAccessor /// /// The type for which to create a repository. /// The ids to filter on. - Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids); + Task> GetResourcesByIdAsync(Type resourceType, IReadOnlyCollection ids); } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 5f3dfaa1e2..c19c8547bb 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -24,21 +24,35 @@ public interface IResourceWriteRepository /// Task CreateAsync(TResource resource); + /// + /// Adds a value to a relationship collection in the underlying data store. + /// + Task AddRelationshipAsync(TId id, IReadOnlyCollection relationshipAssignment); + /// /// Updates an existing resource in the underlying data store. /// /// The (partial) resource coming from the request body. /// The resource as stored in the database before the update. - /// Enable complete replacements when dealing with relationship assignments. - Task UpdateAsync(TResource requestResource, TResource databaseResource, bool completeReplacementOfRelationships = true); + Task UpdateAsync(TResource requestResource, TResource databaseResource); + /// + /// Performs a complete replacement of a relationship in the underlying data store. + /// + Task SetRelationshipAsync(TId id, object relationshipAssignment); + /// /// Deletes a resource from the underlying data store. /// /// Identifier for the resource to delete. /// true if the resource was deleted; false is the resource did not exist. - Task DeleteAsync(TId id); - + Task DeleteAsync(TId id); + + /// + /// Removes a value from a relationship collection in the underlying data store. + /// + Task DeleteRelationshipAsync(TId id, IReadOnlyCollection relationshipAssignment); + /// /// Ensures that the next time this resource is requested, it is re-fetched from the underlying data store. /// diff --git a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs index 4545d41a41..dd666dbf4e 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs @@ -17,18 +17,21 @@ public class ResourceAccessor : IResourceAccessor { private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); private static readonly MethodInfo _accessorMethod; - + static ResourceAccessor() { - _accessorMethod = typeof(ResourceAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); + _accessorMethod = + typeof(ResourceAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); } - + private readonly IServiceProvider _serviceProvider; private readonly IResourceContextProvider _provider; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Dictionary _parameterizedMethodRepositoryCache = new Dictionary(); - + + private readonly Dictionary _parameterizedMethodRepositoryCache = + new Dictionary(); + public ResourceAccessor( IServiceProvider serviceProvider, IResourceContextProvider provider, @@ -38,63 +41,64 @@ public ResourceAccessor( _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); _queryLayerComposer = composer ?? throw new ArgumentException(nameof(composer)); - _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentException(nameof(resourceDefinitionAccessor)); + _resourceDefinitionAccessor = resourceDefinitionAccessor ?? + throw new ArgumentException(nameof(resourceDefinitionAccessor)); } /// - public async Task> GetResourcesByIdAsync(Type resourceType, IEnumerable ids) + public async Task> GetResourcesByIdAsync(Type resourceType, + IReadOnlyCollection ids) { var resourceContext = _provider.GetResourceContext(resourceType); var (parameterizedMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); - + var resources = await parameterizedMethod.InvokeAsync(this, ids, repository, resourceContext); - - return (IEnumerable)resources; + + return (IEnumerable) resources; } - private (MethodInfo, object) GetParameterizedMethodAndRepository(Type resourceType, ResourceContext resourceContext) + private (MethodInfo, object) GetParameterizedMethodAndRepository(Type resourceType, + ResourceContext resourceContext) { if (!_parameterizedMethodRepositoryCache.TryGetValue(resourceType, out var accessorPair)) { var parameterizedMethod = _accessorMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); - var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); + var repositoryType = + _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); var repository = _serviceProvider.GetRequiredService(repositoryType); - + accessorPair = (parameterizedMethod, repository); _parameterizedMethodRepositoryCache.Add(resourceType, accessorPair); } return accessorPair; } - + private async Task> GetById( - IEnumerable ids, + IReadOnlyCollection ids, IResourceReadRepository repository, ResourceContext resourceContext) where TResource : class, IIdentifiable { var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - var equalsAnyOfFilter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), - ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()); - var filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, equalsAnyOfFilter); - - var projection = new Dictionary { { idAttribute, null } }; - + var idExpressions = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToArray(); + var equalsAnyOfFilter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), idExpressions); + var queryLayer = new QueryLayer(resourceContext) { - Filter = filter, + Filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, equalsAnyOfFilter) }; // Only apply projection when there is no resource inheritance. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844. // We can leave it out because the projection here is an optimization, not a functional requirement. if (!resourceContext.ResourceType.GetTypeInfo().IsAbstract) { + var projection = new Dictionary {{idAttribute, null}}; queryLayer.Projection = projection; } - + return await repository.GetAsync(queryLayer); } } } - diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 1ed2356ff7..65ff76f4bb 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -16,7 +16,7 @@ public interface IResourceFactory /// /// Creates a new resource object instance. /// - public TResource CreateInstance(); + public TResource CreateInstance(object id = null) where TResource : IIdentifiable; /// /// Returns an expression tree that represents creating a new resource object instance. diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 4a9ed64bfe..0570a13e0c 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -30,9 +30,16 @@ public object CreateInstance(Type resourceType) } /// - public TResource CreateInstance() + public TResource CreateInstance(object id = null) where TResource : IIdentifiable { - return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); + var identifiable = (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); + + if (id != null) + { + TypeHelper.SetResourceTypedId(identifiable, id); + } + + return identifiable; } private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 4082afa3d8..145bd7b06c 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -29,7 +29,7 @@ public RequestDeserializer( { _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); - _request = request ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + _request = request ?? throw new ArgumentNullException(nameof(request)); } /// diff --git a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs index 7936ae7f83..5b4f99c2a9 100644 --- a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs @@ -15,6 +15,6 @@ public interface IAddRelationshipService where TResource : cl /// /// Handles a json:api request to add resources to a to-many relationship. /// - Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipAssignment); + Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection relationshipAssignment); } } diff --git a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs index 0abbf3e0a2..8fe92f74af 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs @@ -14,6 +14,6 @@ public interface IDeleteRelationshipService where TResource : /// /// Handles a json:api request to remove resources from a to-many relationship. /// - Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable removals); + Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removals); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 973130c1fe..0218bfb58c 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -143,26 +143,12 @@ private async Task GetPrimaryResourceById(TId id, bool allowTopSparse return primaryResource; } - - private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) - { - var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - - FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); - - return existingFilter == null - ? filterById - : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); - } - + /// // triggered by GET /articles/{id}/{relationshipName} public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - AssertRelationshipExists(relationshipName); _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); @@ -205,8 +191,6 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - AssertRelationshipExists(relationshipName); _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); @@ -243,12 +227,19 @@ public virtual async Task CreateAsync(TResource resource) resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); } - if (HasNonNullRelationshipAssignments(resource, out var assignments)) + try { - await AssertValuesOfRelationshipAssignmentExistAsync(assignments); + await _repository.CreateAsync(resource); + } + catch (QueryExecutionException) + { + if (HasNonNullRelationshipAssignments(resource, out var assignments)) + { + await AssertValuesOfRelationshipAssignmentExistAsync(assignments); + } + + throw; } - - await _repository.CreateAsync(resource); resource = await GetPrimaryResourceById(resource.Id, true); @@ -263,23 +254,29 @@ public virtual async Task CreateAsync(TResource resource) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IEnumerable relationshipAssignment) + public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection relationshipAssignment) { - relationshipAssignment = relationshipAssignment.ToArray(); - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); + _traceWriter.LogMethodStart(new {id, relationshipAssignment}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - - var primaryResource = await GetProjectedPrimaryResourceById(id); - AssertPrimaryResourceExists(primaryResource); + AssertRelationshipIsToMany(); if (relationshipAssignment.Any()) { - await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); - - var requestResource = CreateRequestResource(relationshipAssignment, primaryResource); - await _repository.UpdateAsync(requestResource, primaryResource, completeReplacementOfRelationships: false); + try + { + await _repository.AddRelationshipAsync(id, relationshipAssignment); + } + catch (QueryExecutionException) + { + var primaryResource = await GetProjectedPrimaryResourceById(id); + AssertPrimaryResourceExists(primaryResource); + + await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); + + throw; + } } } @@ -291,11 +288,6 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); TResource databaseResource = await GetPrimaryResourceById(id, false); - - if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) - { - await AssertValuesOfRelationshipAssignmentExistAsync(assignments); - } _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); _resourceChangeTracker.SetRequestedAttributeValues(requestResource); @@ -304,9 +296,21 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour { requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); } - - await _repository.UpdateAsync(requestResource, databaseResource); - + + try + { + await _repository.UpdateAsync(requestResource, databaseResource); + } + catch (QueryExecutionException) + { + if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) + { + await AssertValuesOfRelationshipAssignmentExistAsync(assignments); + } + + throw; + } + if (_hookExecutor != null) { _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); @@ -329,53 +333,40 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - - var primaryResource = await GetProjectedPrimaryResourceById(id); - AssertPrimaryResourceExists(primaryResource); - if (relationshipAssignment != null) - { - await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); - } + TResource primaryResource = null; if (_hookExecutor != null) { - primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); + primaryResource = await GetProjectedPrimaryResourceById(id); + AssertPrimaryResourceExists(primaryResource); + _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); } - - var requestResource = CreateRequestResource(relationshipAssignment, primaryResource); - - await _repository.UpdateAsync(requestResource, primaryResource); - if (_hookExecutor != null && primaryResource != null) + try { - _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + await _repository.SetRelationshipAsync(id, relationshipAssignment); } - } - - private async Task GetProjectedPrimaryResourceById(TId id, bool includeRequestRelationship = false) - { - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - - queryLayer.Filter = IncludeFilterById(id, queryLayer.Filter); - - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - queryLayer.Projection = new Dictionary {{idAttribute, null}}; - - if (includeRequestRelationship) + catch (QueryExecutionException) { - queryLayer.Include = IncludeRelationshipExpression(_request.Relationship); - var relationshipResourceContext = _provider.GetResourceContext(_request.Relationship.RightType); - var relationshipIdAttribute = relationshipResourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - var secondaryLayer = new QueryLayer(relationshipResourceContext) + if (primaryResource == null) + { + primaryResource = await GetProjectedPrimaryResourceById(id); + AssertPrimaryResourceExists(primaryResource); + } + + if (relationshipAssignment != null) { - Projection = new Dictionary {{relationshipIdAttribute, null}} - }; - queryLayer.Projection.Add(_request.Relationship, secondaryLayer); + await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); + } + + throw; } - var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); - return primaryResource; + if (_hookExecutor != null && primaryResource != null) + { + _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + } } /// @@ -384,52 +375,82 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); + TResource resource = null; if (_hookExecutor != null) { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - + resource = _resourceFactory.CreateInstance(id); _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); } - var succeeded = await _repository.DeleteAsync(id); - - if (_hookExecutor != null) + var succeeded = true; + + try { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); + await _repository.DeleteAsync(id); } + catch (QueryExecutionException) + { + succeeded = false; + resource = await GetProjectedPrimaryResourceById(id); + AssertPrimaryResourceExists(resource); - if (!succeeded) + throw; + } + finally { - AssertPrimaryResourceExists(null); + _hookExecutor?.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); } } /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public async Task DeleteRelationshipAsync(TId id, string relationshipName, IEnumerable removals) + public async Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removals) { _traceWriter.LogMethodStart(new {id, relationshipName, removals}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); + AssertRelationshipIsToMany(); - var primaryResource = await GetProjectedPrimaryResourceById(id, includeRequestRelationship: true); - AssertPrimaryResourceExists(primaryResource); - - var currentAssignment = ((IEnumerable)_request.Relationship.GetValue(primaryResource)).ToArray(); - var newAssignment = currentAssignment.Where(i => removals.All(r => r.StringId != i.StringId)).ToArray(); - - if (newAssignment.Length < currentAssignment.Length) + try { - var requestResource = CreateRequestResource(newAssignment, primaryResource); - await _repository.UpdateAsync(requestResource, primaryResource, completeReplacementOfRelationships: true); + await _repository.DeleteRelationshipAsync(id, removals); + } + catch (QueryExecutionException) + { + var resource = await GetProjectedPrimaryResourceById(id); + AssertPrimaryResourceExists(resource); + + throw; } } + private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) + { + var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + + return existingFilter == null + ? filterById + : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); + } + + private async Task GetProjectedPrimaryResourceById(TId id) + { + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + + queryLayer.Filter = IncludeFilterById(id, queryLayer.Filter); + + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + queryLayer.Projection = new Dictionary {{idAttribute, null}}; + + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); + + return primaryResource; + } + private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) { assignments = _targetedFields.Relationships @@ -441,7 +462,7 @@ private bool HasNonNullRelationshipAssignments(TResource requestResource, out (R return t.Item2 != null; } - return ((IEnumerable) t.Item2).Any(); + return ((IReadOnlyCollection) t.Item2).Any(); }).ToArray(); return assignments.Any(); @@ -464,12 +485,12 @@ private void AssertRelationshipExists(string relationshipName) } } - private void AssertRelationshipIsToMany(string relationshipName) + private void AssertRelationshipIsToMany() { var relationship = _request.Relationship; if (!(relationship is HasManyAttribute)) { - throw new RequestMethodNotAllowedException(relationship.PublicName); + throw new RelationshipUpdateForbiddenException(relationship.PublicName); } } @@ -478,18 +499,18 @@ private async Task AssertValuesOfRelationshipAssignmentExistAsync(params (Relati var nonExistingResources = new Dictionary>(); foreach (var (relationship, assignmentValue) in assignments) { - IEnumerable identifiers; + IReadOnlyCollection identifiers; if (assignmentValue is IIdentifiable identifiable) { - identifiers = new [] { TypeHelper.GetIdValue(identifiable) }; + identifiers = new [] { TypeHelper.GetResourceStringId(identifiable) }; } else { - identifiers = ((IEnumerable) assignmentValue).Select(TypeHelper.GetIdValue).ToArray(); + identifiers = ((IReadOnlyCollection) assignmentValue).Select(TypeHelper.GetResourceStringId).ToArray(); } var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); - var missing = identifiers.Where(id => resources.All(r => TypeHelper.GetIdValue(r) != id)).ToArray(); + var missing = identifiers.Where(id => resources.All(r => TypeHelper.GetResourceStringId(r) != id)).ToArray(); if (missing.Any()) { nonExistingResources.Add(_provider.GetResourceContext(relationship.RightType).PublicName, missing.ToArray()); @@ -506,22 +527,7 @@ private IncludeExpression IncludeRelationshipExpression(RelationshipAttribute re { return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); } - - private TResource CreateRequestResource(object relationshipAssignment, TResource primaryResource) - { - var requestResource = _resourceFactory.CreateInstance(); - requestResource.StringId = primaryResource.StringId; - - if (_request.Relationship is HasManyAttribute) - { - relationshipAssignment = TypeHelper.CopyToTypedCollection((IEnumerable) relationshipAssignment, _request.Relationship.Property.PropertyType); - } - - _request.Relationship.SetValue(requestResource, relationshipAssignment, _resourceFactory); - - return requestResource; - } - + private List AsList(TResource resource) { return new List { resource }; diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 28d031a4d6..e9b26a4550 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -246,29 +246,6 @@ public static Type GetIdType(Type resourceType) return property.PropertyType; } - /// - /// Gets the value of the id of an identifiable. This can be useful to use over `StringId` because this might - /// fail when the model has obfuscated IDs. - /// - public static string GetIdValue(IIdentifiable identifiable) - { - if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); - var typedId = GetTypedIdValue(identifiable); - - return typedId.ToString(); - } - - /// - /// Gets the typed value of the id of an identifiable. - /// - public static object GetTypedIdValue(IIdentifiable identifiable) - { - if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); - var typedId = identifiable.GetType().GetProperty(nameof(Identifiable.Id)).GetValue(identifiable); - - return typedId; - } - public static object CreateInstance(Type type) { if (type == null) @@ -293,12 +270,37 @@ public static object ConvertStringIdToTypedId(Type resourceType, string stringId tempResource.StringId = stringId; return GetResourceTypedId(tempResource); } + + /// + /// Gets the value of the id of an identifiable. This can be useful to use over `StringId` because this might + /// fail when the model has obfuscated IDs. + /// + public static string GetResourceStringId(IIdentifiable identifiable) + { + return GetResourceTypedId(identifiable).ToString(); + } + /// + /// Gets the typed value of the id of an identifiable. + /// public static object GetResourceTypedId(IIdentifiable resource) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); PropertyInfo property = resource.GetType().GetProperty(nameof(Identifiable.Id)); + return property.GetValue(resource); } + + /// + /// Gets the typed value of the id of an identifiable. + /// + public static void SetResourceTypedId(IIdentifiable identifiable, object id) + { + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + if (id == null) throw new ArgumentNullException(nameof(id)); + + identifiable.GetType().GetProperty(nameof(Identifiable.Id)).SetValue(identifiable, id); + } /// /// Extension to use the LINQ cast method in a non-generic way: diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 6742ab6d80..8ae419de1e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -141,7 +141,7 @@ public async Task Unauthorized_TodoItem() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -161,7 +161,7 @@ public async Task Unauthorized_Passport() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -186,7 +186,7 @@ public async Task Unauthorized_Article() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -304,7 +304,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -355,7 +355,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -406,7 +406,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -435,7 +435,7 @@ public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -489,7 +489,7 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -546,7 +546,7 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -575,7 +575,7 @@ public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 3c65dec700..7e72451e0f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -329,7 +329,8 @@ public async Task When_getting_unknown_related_resource_it_should_fail() var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + AssertEqualStatusCode(HttpStatusCode.NotFound, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -338,6 +339,11 @@ public async Task When_getting_unknown_related_resource_it_should_fail() Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } + protected void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) + { + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); + } + [Fact] public async Task When_getting_unknown_relationship_for_resource_it_should_fail() { @@ -359,7 +365,7 @@ public async Task When_getting_unknown_relationship_for_resource_it_should_fail( var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); From f4853af27b675e73e419cb792c154a147a9cbfaa Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 8 Oct 2020 13:13:06 +0200 Subject: [PATCH 027/240] chore: refactor --- .../JsonApiDeserializerBenchmarks.cs | 4 +- .../Controllers/TodoItemsTestController.cs | 4 +- .../Services/CustomArticleService.cs | 4 +- .../Services/WorkItemService.cs | 12 +- .../JsonApiApplicationBuilder.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 18 +-- .../Controllers/JsonApiCommandController.cs | 12 +- .../Controllers/JsonApiController.cs | 12 +- ...urceAccessor.cs => IRepositoryAccessor.cs} | 2 +- ...ourceAccessor.cs => RepositoryAccessor.cs} | 34 +++--- .../Resources/ResourceFactory.cs | 14 +-- .../Services/IAddRelationshipService.cs | 2 +- .../Services/IDeleteRelationshipService.cs | 2 +- .../Services/ISetRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 109 ++++++++++-------- src/JsonApiDotNetCore/TypeHelper.cs | 35 ++++++ .../ServiceDiscoveryFacadeTests.cs | 6 +- .../IServiceCollectionExtensionsTests.cs | 12 +- .../Services/DefaultResourceService_Tests.cs | 2 +- 19 files changed, 168 insertions(+), 120 deletions(-) rename src/JsonApiDotNetCore/Repositories/{IResourceAccessor.cs => IRepositoryAccessor.cs} (94%) rename src/JsonApiDotNetCore/Repositories/{ResourceAccessor.cs => RepositoryAccessor.cs} (72%) diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index 6a373eb73c..3ca960ef87 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -3,6 +3,7 @@ using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; @@ -37,7 +38,8 @@ public JsonApiDeserializerBenchmarks() var options = new JsonApiOptions(); IResourceGraph resourceGraph = DependencyFactory.CreateResourceGraph(options); var targetedFields = new TargetedFields(); - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor()); + var request = new JsonApiRequest(); + _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor(), request); } [Benchmark] diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index 2480d40f04..c1c3b614ca 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -69,8 +69,8 @@ public override async Task PatchAsync(int id, [FromBody] TodoItem [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - int id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + int id, string relationshipName, [FromBody] object newValues) + => await base.PatchRelationshipAsync(id, relationshipName, newValues); [HttpDelete("{id}")] public override async Task DeleteAsync(int id) diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index f03e2cb600..6a44fda4f0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -22,12 +22,12 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - IResourceAccessor resourceAccessor, + IRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, resourceAccessor, targetedFields, provider, hookExecutor) + resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, provider, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 70c100c414..c41e16d84d 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -67,7 +67,7 @@ public Task UpdateAsync(int id, WorkItem requestResource) throw new NotImplementedException(); } - public Task SetRelationshipAsync(int id, string relationshipName, object relationshipAssignment) + public Task SetRelationshipAsync(int id, string relationshipName, object newValue) { throw new NotImplementedException(); } @@ -89,5 +89,15 @@ public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable { throw new NotImplementedException(); } + + public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection newValues) + { + throw new NotImplementedException(); + } + + public Task DeleteRelationshipAsync(int id, string relationshipName, IReadOnlyCollection removalValues) + { + throw new NotImplementedException(); + } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 747082609b..c786b200ea 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -196,7 +196,7 @@ private void AddRepositoryLayer() : intTypedResourceService); } - _services.AddScoped(); + _services.AddScoped(); } private void AddServiceLayer() diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 237cf4972a..ed49dfbdb3 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -177,13 +177,13 @@ public virtual async Task PostAsync([FromBody] TResource resource /// /// Adds resources to a to-many relationship. /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection relationshipAssignment) + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection newValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); + _traceWriter.LogMethodStart(new {id, relationshipName, newValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_addRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _addRelationship.AddRelationshipAsync(id, relationshipName, relationshipAssignment); + await _addRelationship.AddRelationshipAsync(id, relationshipName, newValues); return Ok(); } @@ -213,13 +213,13 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource /// /// Updates a relationship. /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationshipAssignment) + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object newValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); + _traceWriter.LogMethodStart(new {id, relationshipName, newValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _setRelationship.SetRelationshipAsync(id, relationshipName, relationshipAssignment); + await _setRelationship.SetRelationshipAsync(id, relationshipName, newValues); return Ok(); } @@ -240,13 +240,13 @@ public virtual async Task DeleteAsync(TId id) /// /// Removes resources from a to-many relationship. /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removals) + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removalValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, removals}); + _traceWriter.LogMethodStart(new {id, relationshipName, removalValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_deleteRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, removals); + await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, removalValues); return Ok(); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index ea576a752c..b93e93a102 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -33,8 +33,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection relationshipAssignment) - => await base.PostRelationshipAsync(id, relationshipName, relationshipAssignment); + TId id, string relationshipName, [FromBody] IReadOnlyCollection newValues) + => await base.PostRelationshipAsync(id, relationshipName, newValues); /// [HttpPatch("{id}")] @@ -44,8 +44,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationshipAssignment) - => await base.PatchRelationshipAsync(id, relationshipName, relationshipAssignment); + TId id, string relationshipName, [FromBody] object newValues) + => await base.PatchRelationshipAsync(id, relationshipName, newValues); /// [HttpDelete("{id}")] @@ -53,8 +53,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removals) - => await base.DeleteRelationshipAsync(id, relationshipName, removals); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removalValues) + => await base.DeleteRelationshipAsync(id, relationshipName, removalValues); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 07600ffe92..60a82909b9 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -69,8 +69,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection relationshipAssignment) - => await base.PostRelationshipAsync(id, relationshipName, relationshipAssignment); + TId id, string relationshipName, [FromBody] IReadOnlyCollection newValues) + => await base.PostRelationshipAsync(id, relationshipName, newValues); /// [HttpPatch("{id}")] @@ -82,8 +82,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationshipAssignment) - => await base.PatchRelationshipAsync(id, relationshipName, relationshipAssignment); + TId id, string relationshipName, [FromBody] object newValues) + => await base.PatchRelationshipAsync(id, relationshipName, newValues); /// [HttpDelete("{id}")] @@ -91,8 +91,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removals) - => await base.DeleteRelationshipAsync(id, relationshipName, removals); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removalValues) + => await base.DeleteRelationshipAsync(id, relationshipName, removalValues); } /// diff --git a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs similarity index 94% rename from src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs rename to src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs index d1acd85d8e..3aed6e1b95 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Repositories /// /// Retrieves a instance from the D/I container and invokes a callback on it. /// - public interface IResourceAccessor + public interface IRepositoryAccessor { /// /// Gets resources by id. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs b/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs similarity index 72% rename from src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs rename to src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs index dd666dbf4e..724b7ba102 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs @@ -13,36 +13,30 @@ namespace JsonApiDotNetCore.Repositories { /// - public class ResourceAccessor : IResourceAccessor + public class RepositoryAccessor : IRepositoryAccessor { private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); - private static readonly MethodInfo _accessorMethod; + private static readonly MethodInfo _openGetByIdMethod; - static ResourceAccessor() + static RepositoryAccessor() { - _accessorMethod = - typeof(ResourceAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); + _openGetByIdMethod = typeof(RepositoryAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); } private readonly IServiceProvider _serviceProvider; private readonly IResourceContextProvider _provider; - private readonly IQueryLayerComposer _queryLayerComposer; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Dictionary _parameterizedMethodRepositoryCache = - new Dictionary(); + private readonly Dictionary _parameterizedMethodRepositoryCache = new Dictionary(); - public ResourceAccessor( + public RepositoryAccessor( IServiceProvider serviceProvider, IResourceContextProvider provider, - IQueryLayerComposer composer, IResourceDefinitionAccessor resourceDefinitionAccessor) { _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); - _queryLayerComposer = composer ?? throw new ArgumentException(nameof(composer)); - _resourceDefinitionAccessor = resourceDefinitionAccessor ?? - throw new ArgumentException(nameof(resourceDefinitionAccessor)); + _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentException(nameof(resourceDefinitionAccessor)); } /// @@ -50,9 +44,9 @@ public async Task> GetResourcesByIdAsync(Type resourc IReadOnlyCollection ids) { var resourceContext = _provider.GetResourceContext(resourceType); - var (parameterizedMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); + var (getByIdMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); - var resources = await parameterizedMethod.InvokeAsync(this, ids, repository, resourceContext); + var resources = await getByIdMethod.InvokeAsync(this, ids, repository, resourceContext); return (IEnumerable) resources; } @@ -62,9 +56,9 @@ public async Task> GetResourcesByIdAsync(Type resourc { if (!_parameterizedMethodRepositoryCache.TryGetValue(resourceType, out var accessorPair)) { - var parameterizedMethod = _accessorMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); - var repositoryType = - _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); + var parameterizedMethod = _openGetByIdMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); + + var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); var repository = _serviceProvider.GetRequiredService(repositoryType); accessorPair = (parameterizedMethod, repository); @@ -77,7 +71,7 @@ public async Task> GetResourcesByIdAsync(Type resourc private async Task> GetById( IReadOnlyCollection ids, IResourceReadRepository repository, - ResourceContext resourceContext) + ResourceContext resourceContext) where TResource : class, IIdentifiable { var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); @@ -92,7 +86,7 @@ private async Task> GetById( // Only apply projection when there is no resource inheritance. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844. // We can leave it out because the projection here is an optimization, not a functional requirement. - if (!resourceContext.ResourceType.GetTypeInfo().IsAbstract) + if (!resourceContext.ResourceType.IsAbstract) { var projection = new Dictionary {{idAttribute, null}}; queryLayer.Projection = projection; diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index d121e65724..009bdf5e21 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -129,19 +129,7 @@ private static ConstructorInfo GetLongestConstructor(Type type) throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); } - ConstructorInfo bestMatch = constructors[0]; - int maxParameterLength = constructors[0].GetParameters().Length; - - for (int index = 1; index < constructors.Length; index++) - { - var constructor = constructors[index]; - int length = constructor.GetParameters().Length; - if (length > maxParameterLength) - { - bestMatch = constructor; - maxParameterLength = length; - } - } + var bestMatch = TypeHelper.GetLongestConstructor(constructors); return bestMatch; } diff --git a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs index 5b4f99c2a9..376662f24c 100644 --- a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs @@ -15,6 +15,6 @@ public interface IAddRelationshipService where TResource : cl /// /// Handles a json:api request to add resources to a to-many relationship. /// - Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection relationshipAssignment); + Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection newValues); } } diff --git a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs index 8fe92f74af..e2675b8b82 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs @@ -14,6 +14,6 @@ public interface IDeleteRelationshipService where TResource : /// /// Handles a json:api request to remove resources from a to-many relationship. /// - Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removals); + Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removalValues); } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 692daf2b66..56408280b4 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -14,6 +14,6 @@ public interface ISetRelationshipService where TResource : cl /// /// Handles a json:api request to update an existing relationship. /// - Task SetRelationshipAsync(TId id, string relationshipName, object relationshipAssignment); + Task SetRelationshipAsync(TId id, string relationshipName, object newValues); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0218bfb58c..bd7506a929 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -30,7 +30,7 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly IResourceAccessor _resourceAccessor; + private readonly IRepositoryAccessor _repositoryAccessor; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _provider; private readonly IResourceHookExecutor _hookExecutor; @@ -44,7 +44,7 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceAccessor resourceAccessor, + IRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) @@ -59,7 +59,7 @@ public JsonApiResourceService( _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _resourceAccessor = resourceAccessor ?? throw new ArgumentNullException(nameof(resourceAccessor)); + _repositoryAccessor = repositoryAccessor ?? throw new ArgumentNullException(nameof(repositoryAccessor)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _hookExecutor = hookExecutor; @@ -233,11 +233,9 @@ public virtual async Task CreateAsync(TResource resource) } catch (QueryExecutionException) { - if (HasNonNullRelationshipAssignments(resource, out var assignments)) - { - await AssertValuesOfRelationshipAssignmentExistAsync(assignments); - } - + var nonNullAssignments = GetNonNullAssignments(resource); + await AssertValuesOfRelationshipAssignmentExistAsync(nonNullAssignments); + throw; } @@ -254,26 +252,27 @@ public virtual async Task CreateAsync(TResource resource) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection relationshipAssignment) + public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection newValues) { - _traceWriter.LogMethodStart(new {id, relationshipAssignment}); + _traceWriter.LogMethodStart(new { id, newValues }); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); - - if (relationshipAssignment.Any()) + + if (newValues.Any()) { try { - await _repository.AddRelationshipAsync(id, relationshipAssignment); + await _repository.AddRelationshipAsync(id, newValues); } catch (QueryExecutionException) { var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); - await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); + var assignment = new RelationshipAssignment(_request.Relationship, newValues); + await AssertValuesOfRelationshipAssignmentExistAsync(assignment); throw; } @@ -303,10 +302,9 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour } catch (QueryExecutionException) { - if (HasNonNullRelationshipAssignments(requestResource, out var assignments)) - { - await AssertValuesOfRelationshipAssignmentExistAsync(assignments); - } + var assignments = GetNonNullAssignments(requestResource); + await AssertValuesOfRelationshipAssignmentExistAsync(assignments); + throw; } @@ -327,9 +325,9 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour /// // triggered by PATCH /articles/{id}/relationships/{relationshipName} - public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object relationshipAssignment) + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object newValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationshipAssignment}); + _traceWriter.LogMethodStart(new {id, relationshipName, newValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -345,7 +343,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, try { - await _repository.SetRelationshipAsync(id, relationshipAssignment); + await _repository.SetRelationshipAsync(id, newValues); } catch (QueryExecutionException) { @@ -355,9 +353,10 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertPrimaryResourceExists(primaryResource); } - if (relationshipAssignment != null) + if (newValues != null) { - await AssertValuesOfRelationshipAssignmentExistAsync((_request.Relationship, relationshipAssignment)); + var assignment = new RelationshipAssignment(_request.Relationship, newValues); + await AssertValuesOfRelationshipAssignmentExistAsync(assignment); } throw; @@ -404,9 +403,9 @@ public virtual async Task DeleteAsync(TId id) /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public async Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removals) + public async Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removalValues) { - _traceWriter.LogMethodStart(new {id, relationshipName, removals}); + _traceWriter.LogMethodStart(new {id, relationshipName, removalValues}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -414,7 +413,7 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IRead try { - await _repository.DeleteRelationshipAsync(id, removals); + await _repository.DeleteRelationshipAsync(id, removalValues); } catch (QueryExecutionException) { @@ -425,6 +424,19 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IRead } } + private readonly struct RelationshipAssignment + { + public RelationshipAttribute Relationship { get; } + + public object Value { get; } + + public RelationshipAssignment(RelationshipAttribute relationship, object value) + { + Relationship = relationship; + Value = value; + } + } + private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) { var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); @@ -444,28 +456,34 @@ private async Task GetProjectedPrimaryResourceById(TId id) queryLayer.Filter = IncludeFilterById(id, queryLayer.Filter); var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - queryLayer.Projection = new Dictionary {{idAttribute, null}}; + + if (!TypeHelper.ConstructorDependsOnDbContext(_request.PrimaryResource.ResourceType)) + { + // https://github.com/dotnet/efcore/issues/20502 + queryLayer.Projection = new Dictionary { { idAttribute, null } }; + } + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); return primaryResource; } - private bool HasNonNullRelationshipAssignments(TResource requestResource, out (RelationshipAttribute, object)[] assignments) + private RelationshipAssignment[] GetNonNullAssignments(TResource requestResource) { - assignments = _targetedFields.Relationships - .Select(attr => (attr, attr.GetValue(requestResource))) - .Where(t => + var assignments = _targetedFields.Relationships + .Select(relationship => new RelationshipAssignment (relationship ,relationship.GetValue(requestResource))) + .Where(p => { - if (t.Item1 is HasOneAttribute) + return p.Value switch { - return t.Item2 != null; - } - - return ((IReadOnlyCollection) t.Item2).Any(); + IIdentifiable hasOneAssignment => true, + IReadOnlyCollection hasManyAssignment => hasManyAssignment.Any(), + null => false + }; }).ToArray(); - return assignments.Any(); + return assignments; } private void AssertPrimaryResourceExists(TResource resource) @@ -494,26 +512,27 @@ private void AssertRelationshipIsToMany() } } - private async Task AssertValuesOfRelationshipAssignmentExistAsync(params (RelationshipAttribute relationship, object assignmentValue)[] assignments) + private async Task AssertValuesOfRelationshipAssignmentExistAsync(params RelationshipAssignment[] nonNullRelationshipAssignments) { var nonExistingResources = new Dictionary>(); - foreach (var (relationship, assignmentValue) in assignments) + foreach (var assignment in nonNullRelationshipAssignments) { IReadOnlyCollection identifiers; - if (assignmentValue is IIdentifiable identifiable) + if (assignment.Value is IIdentifiable identifiable) { identifiers = new [] { TypeHelper.GetResourceStringId(identifiable) }; } else { - identifiers = ((IReadOnlyCollection) assignmentValue).Select(TypeHelper.GetResourceStringId).ToArray(); + identifiers = ((IReadOnlyCollection) assignment.Value).Select(TypeHelper.GetResourceStringId).ToArray(); } - var resources = await _resourceAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); + var resources = await _repositoryAccessor.GetResourcesByIdAsync(assignment.Relationship.RightType, identifiers); var missing = identifiers.Where(id => resources.All(r => TypeHelper.GetResourceStringId(r) != id)).ToArray(); + if (missing.Any()) { - nonExistingResources.Add(_provider.GetResourceContext(relationship.RightType).PublicName, missing.ToArray()); + nonExistingResources.Add(_provider.GetResourceContext(assignment.Relationship.RightType).PublicName, missing.ToArray()); } } @@ -551,12 +570,12 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceAccessor resourceAccessor, + IRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, resourceAccessor, targetedFields, provider, hookExecutor) + resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, provider, hookExecutor) { } } } diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 9ec234e597..77ffb90842 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -6,6 +6,7 @@ using System.Reflection; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore { @@ -380,5 +381,39 @@ public static bool IsOrImplementsInterface(Type source, Type interfaceType) return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); } + + internal static ConstructorInfo GetLongestConstructor(ConstructorInfo[] constructors) + { + ConstructorInfo bestMatch = constructors[0]; + int maxParameterLength = constructors[0].GetParameters().Length; + + for (int index = 1; index < constructors.Length; index++) + { + var constructor = constructors[index]; + int length = constructor.GetParameters().Length; + if (length > maxParameterLength) + { + bestMatch = constructor; + maxParameterLength = length; + } + } + + return bestMatch; + } + + internal static bool ConstructorDependsOnDbContext(Type resourceType) + { + var constructors = resourceType.GetConstructors().Where(c => !c.IsStatic).ToArray(); + if (constructors.Any()) + { + var dbContextType = typeof(DbContext); + var constructor = GetLongestConstructor(constructors); + + return constructor.GetParameters().Any(p => dbContextType.IsAssignableFrom(p.ParameterType)); + } + + return false; + + } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index a293d2efc6..d6d8215fbc 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -39,7 +39,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -154,12 +154,12 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceAccessor resourceAccessor, + IRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, resourceAccessor, targetedFields, provider, hookExecutor) + resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, provider, hookExecutor) { } } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 565e849ea7..47add8b37b 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -166,9 +166,9 @@ private class IntResourceService : IResourceService public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(int id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipAssignment) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable removals) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object newValue) => throw new NotImplementedException(); + public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection newValues) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(int id, string relationshipName, IReadOnlyCollection removalValues) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -180,9 +180,9 @@ private class GuidResourceService : IResourceService public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(Guid id, string relationshipName, object relationshipAssignment) => throw new NotImplementedException(); - public Task AddRelationshipAsync(Guid id, string relationshipName, IEnumerable relationshipAssignment) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(Guid id, string relationshipName, IEnumerable removals) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object newValue) => throw new NotImplementedException(); + public Task AddRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection newValues) => throw new NotImplementedException(); + public Task DeleteRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection removalValues) => throw new NotImplementedException(); } diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index 48d6a8275f..9811da0045 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -76,7 +76,7 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); - var resourceAccessor = new Mock().Object; + var resourceAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; From fee60272abb85bbf844fe99c8b456ae8951b8d64 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 8 Oct 2020 13:13:42 +0200 Subject: [PATCH 028/240] fix: remove test that is no longer expected to pass after refactor of repository/service layer --- .../SoftDeletion/SoftDeletionTests.cs | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index ad9014c213..e69a5ad5e9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -393,48 +393,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); responseDocument.Errors[0].Source.Parameter.Should().BeNull(); } - - [Fact] - public async Task Cannot_set_relationship_for_deleted_parent() - { - // Arrange - var company = new Company - { - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Marketing" - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Companies.Add(company); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/companies/{company.StringId}/relationships/departments"; - - var requestBody = new - { - data = new object[0] - }; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); - responseDocument.Errors[0].Source.Parameter.Should().BeNull(); - } } } From a30c62ce78e9043d79e0807e390f7a47d0f34a08 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 8 Oct 2020 14:18:04 +0200 Subject: [PATCH 029/240] chore: self review --- .../Services/WorkItemService.cs | 22 +++++-------- .../Controllers/BaseJsonApiController.cs | 10 +++--- .../Errors/QueryExecutionException.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 33 +++++++++---------- .../Repositories/IResourceWriteRepository.cs | 6 ++-- .../Resources/IResourceFactory.cs | 3 +- src/JsonApiDotNetCore/TypeHelper.cs | 6 ++-- .../Spec/FetchingRelationshipsTests.cs | 10 ++---- .../Acceptance/Spec/UpdatingDataTests.cs | 2 +- .../Spec/UpdatingRelationshipsTests.cs | 2 +- 10 files changed, 43 insertions(+), 53 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index c41e16d84d..31164f4ce4 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -72,19 +72,6 @@ public Task SetRelationshipAsync(int id, string relationshipName, object newValu throw new NotImplementedException(); } - private async Task> QueryAsync(Func>> query) - { - using IDbConnection dbConnection = GetConnection; - dbConnection.Open(); - return await query(dbConnection); - } - - private IDbConnection GetConnection => new NpgsqlConnection(_connectionString); - public Task AddRelationshipAsync(int id, string relationshipName, IEnumerable relationshipAssignment) - { - throw new NotImplementedException(); - } - public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable removals) { throw new NotImplementedException(); @@ -99,5 +86,14 @@ public Task DeleteRelationshipAsync(int id, string relationshipName, IReadOnlyCo { throw new NotImplementedException(); } + + private async Task> QueryAsync(Func>> query) + { + using IDbConnection dbConnection = GetConnection; + dbConnection.Open(); + return await query(dbConnection); + } + + private IDbConnection GetConnection => new NpgsqlConnection(_connectionString); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index ed49dfbdb3..fa9a2fb756 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -97,7 +97,7 @@ public virtual async Task GetAsync() if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var resources = await _getAll.GetAsync(); - + return Ok(resources); } @@ -111,7 +111,7 @@ public virtual async Task GetAsync(TId id) if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var resource = await _getById.GetAsync(id); - + return Ok(resource); } @@ -128,7 +128,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); - + return Ok(relationship); } @@ -206,7 +206,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } var updated = await _update.UpdateAsync(id, resource); - + return updated == null ? Ok(null) : Ok(updated); } @@ -220,7 +220,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); await _setRelationship.SetRelationshipAsync(id, relationshipName, newValues); - + return Ok(); } diff --git a/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs b/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs index 29f78bc73b..068c108dce 100644 --- a/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs +++ b/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown Entity Framework Core fails executing a query. + /// The error that is thrown when Entity Framework Core fails executing a query. /// public sealed class QueryExecutionException : Exception { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 0d82d004c1..812bcaaa7e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -32,7 +32,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; private readonly TraceLogWriter> _traceWriter; - + public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, @@ -131,15 +131,15 @@ public virtual async Task CreateAsync(TResource resource) DetachRelationships(resource); } - public async Task AddRelationshipAsync(TId id, IReadOnlyCollection relationshipAssignment) + public async Task AddRelationshipAsync(TId id, IReadOnlyCollection newValues) { - _traceWriter.LogMethodStart(new {id, relationshipAssignment}); - if (relationshipAssignment == null) throw new ArgumentNullException(nameof(relationshipAssignment)); + _traceWriter.LogMethodStart(new {id, relationshipAssignment = newValues}); + if (newValues == null) throw new ArgumentNullException(nameof(newValues)); var relationship = _targetedFields.Relationships.Single(); var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); - ApplyRelationshipAssignment(relationshipAssignment, relationship, databaseResource); + ApplyRelationshipAssignment(newValues, relationship, databaseResource); try { @@ -150,17 +150,17 @@ public async Task AddRelationshipAsync(TId id, IReadOnlyCollection(id)); LoadCurrentRelationships(databaseResource, relationship); - ApplyRelationshipAssignment(relationshipAssignment, relationship, databaseResource); + ApplyRelationshipAssignment(newValues, relationship, databaseResource); try { @@ -171,7 +171,7 @@ public async Task SetRelationshipAsync(TId id, object relationshipAssignment) throw new QueryExecutionException(exception); } } - + /// public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) { @@ -224,10 +224,10 @@ public virtual async Task DeleteAsync(TId id) } } - public async Task DeleteRelationshipAsync(TId id, IReadOnlyCollection removals) + public async Task DeleteRelationshipAsync(TId id, IReadOnlyCollection removalValues) { - _traceWriter.LogMethodStart(new {id, removals}); - if (removals == null) throw new ArgumentNullException(nameof(removals)); + _traceWriter.LogMethodStart(new {id, removals = removalValues}); + if (removalValues == null) throw new ArgumentNullException(nameof(removalValues)); var relationship = _targetedFields.Relationships.Single(); var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); @@ -235,7 +235,7 @@ public async Task DeleteRelationshipAsync(TId id, IReadOnlyCollection) relationship.GetValue(databaseResource)); - var newAssignment = currentAssignment.Where(i => removals.All(r => r.StringId != i.StringId)).ToArray(); + var newAssignment = currentAssignment.Where(i => removalValues.All(r => r.StringId != i.StringId)).ToArray(); if (newAssignment.Length < currentAssignment.Count()) { @@ -259,7 +259,7 @@ public virtual void FlushFromCache(TResource resource) _dbContext.Entry(resource).State = EntityState.Detached; } - + /// /// Loads the inverse relationships to prevent foreign key constraints from being violated /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. @@ -299,7 +299,6 @@ private void LoadInverseRelationships(object trackedRelationshipAssignment, Rela } } - // private bool IsOneToOne(string propertyName, Type type) private bool IsOneToOne(HasOneAttribute relationship) { var relationshipType = relationship.RightType; @@ -433,7 +432,7 @@ private object GetTrackedRelationshipValue(RelationshipAttribute relationship, o return trackedRelationshipAssignment; } - + private PropertyInfo GetForeignKeyProperty(RelationshipAttribute relationship) { PropertyInfo foreignKey = null; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index c19c8547bb..64b537c67e 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -27,7 +27,7 @@ public interface IResourceWriteRepository /// /// Adds a value to a relationship collection in the underlying data store. /// - Task AddRelationshipAsync(TId id, IReadOnlyCollection relationshipAssignment); + Task AddRelationshipAsync(TId id, IReadOnlyCollection newValues); /// /// Updates an existing resource in the underlying data store. @@ -39,7 +39,7 @@ public interface IResourceWriteRepository /// /// Performs a complete replacement of a relationship in the underlying data store. /// - Task SetRelationshipAsync(TId id, object relationshipAssignment); + Task SetRelationshipAsync(TId id, object newValues); /// /// Deletes a resource from the underlying data store. @@ -51,7 +51,7 @@ public interface IResourceWriteRepository /// /// Removes a value from a relationship collection in the underlying data store. /// - Task DeleteRelationshipAsync(TId id, IReadOnlyCollection relationshipAssignment); + Task DeleteRelationshipAsync(TId id, IReadOnlyCollection removalValues); /// /// Ensures that the next time this resource is requested, it is re-fetched from the underlying data store. diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 65ff76f4bb..61cd38f30e 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -12,10 +12,11 @@ public interface IResourceFactory /// Creates a new resource object instance. /// public object CreateInstance(Type resourceType); - + /// /// Creates a new resource object instance. /// + /// The id that will be set for the instance, if provided. public TResource CreateInstance(object id = null) where TResource : IIdentifiable; /// diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 77ffb90842..6d11ca14e7 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -293,8 +293,8 @@ public static object ConvertStringIdToTypedId(Type resourceType, string stringId } /// - /// Gets the value of the id of an identifiable. This can be useful to use over `StringId` because this might - /// fail when the model has obfuscated IDs. + /// Gets the string value of the id of an identifiable through the typed value. This can be useful when dealing with + /// obfuscated IDs. /// public static string GetResourceStringId(IIdentifiable identifiable) { @@ -313,7 +313,7 @@ public static object GetResourceTypedId(IIdentifiable resource) } /// - /// Gets the typed value of the id of an identifiable. + /// Sets the typed value of the id of an identifiable. /// public static void SetResourceTypedId(IIdentifiable identifiable, object id) { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 7e72451e0f..3c65dec700 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -329,8 +329,7 @@ public async Task When_getting_unknown_related_resource_it_should_fail() var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - - AssertEqualStatusCode(HttpStatusCode.NotFound, response); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -339,11 +338,6 @@ public async Task When_getting_unknown_related_resource_it_should_fail() Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } - protected void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); - } - [Fact] public async Task When_getting_unknown_relationship_for_resource_it_should_fail() { @@ -365,7 +359,7 @@ public async Task When_getting_unknown_relationship_for_resource_it_should_fail( var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.NotFound, response); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 9c3c493f5d..163e82d4ea 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -322,7 +322,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - + responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 4477c8fcd0..05e76963ef 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -754,7 +754,7 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() // Act var response = await client.SendAsync(request); ; - var responseBody = await response.Content.ReadAsStringAsync(); + // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); _context = _fixture.GetRequiredService(); From baa649f4d4c203345ad95d9982b89772ce2fcb40 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 8 Oct 2020 15:46:51 +0200 Subject: [PATCH 030/240] fix: test --- .../Models/HouseholdInsurance.cs | 41 +++++++++++++++++++ .../Services/JsonApiResourceService.cs | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs b/src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs new file mode 100644 index 0000000000..d08e77af4a --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class HouseholdInsurance : Identifiable + { + public override string Id + { + get => $"{Address}-{Excess}"; + set + { + var split = value.Split('-'); + Address = split[0]; + Address = split[1]; + } + } + + [Attr] + [Required] + public string Address { get; set; } + + [Attr] + [Required] + public Excess Excess { get; set; } + + [Attr] + public decimal MonthlyFee { get; set; } + + [HasOne] + public Person Person { get; set; } + } + + public enum Excess + { + None, + Low, + High, + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index bd7506a929..e8612ab617 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -479,7 +479,7 @@ private RelationshipAssignment[] GetNonNullAssignments(TResource requestResource { IIdentifiable hasOneAssignment => true, IReadOnlyCollection hasManyAssignment => hasManyAssignment.Any(), - null => false + _ => false }; }).ToArray(); From 90a205169272e02219b0eab51073bbbebcca4ba0 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 8 Oct 2020 17:38:57 +0200 Subject: [PATCH 031/240] fix: failing test --- .../NoEntityFrameworkExample/Services/WorkItemService.cs | 7 +------ .../Acceptance/Spec/UpdatingRelationshipsTests.cs | 3 ++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 31164f4ce4..0ba7d188b5 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -71,12 +71,7 @@ public Task SetRelationshipAsync(int id, string relationshipName, object newValu { throw new NotImplementedException(); } - - public Task DeleteRelationshipAsync(int id, string relationshipName, IEnumerable removals) - { - throw new NotImplementedException(); - } - + public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection newValues) { throw new NotImplementedException(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 05e76963ef..1ce6b4a401 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -895,7 +896,7 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() .Single(p => p.Id == person.Id).TodoItems; Assert.Equal(4, assertTodoItems.Count); - Assert.True(assertTodoItems.Any(ati => ati.Id == todoItem.Id)); + Assert.Contains(todoItem, assertTodoItems, IdentifiableComparer.Instance); } [Fact] From 9f5606f1362356aa292baa881d91e04368a2e52f Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 12 Oct 2020 10:08:36 +0200 Subject: [PATCH 032/240] chore: self review --- .../Controllers/TodoItemsCustomController.cs | 5 +- .../Controllers/TodoItemsTestController.cs | 4 +- .../Models/HouseholdInsurance.cs | 41 --- .../Services/CustomArticleService.cs | 4 +- .../Services/WorkItemService.cs | 8 +- .../JsonApiApplicationBuilder.cs | 29 +- .../Configuration/MethodInfoExtensions.cs | 19 -- .../Configuration/ServiceDiscoveryFacade.cs | 8 +- .../Controllers/BaseJsonApiController.cs | 44 +-- .../Controllers/JsonApiCommandController.cs | 12 +- .../Controllers/JsonApiController.cs | 28 +- ...xception.cs => RepositorySaveException.cs} | 6 +- .../Errors/ResourceNotFoundException.cs | 8 +- ...neRelationshipUpdateForbiddenException.cs} | 6 +- .../Internal/Execution/HookExecutorHelper.cs | 2 +- .../Repositories/DbContextExtensions.cs | 32 +- .../EntityFrameworkCoreRepository.cs | 310 +++++++++--------- .../Repositories/IResourceWriteRepository.cs | 22 +- .../Repositories/RepositoryAccessor.cs | 18 +- .../Resources/IIdentifiableExtensions.cs | 18 + .../Services/IAddRelationshipService.cs | 20 -- .../Services/IAddToRelationshipService.cs | 22 ++ .../Services/IDeleteRelationshipService.cs | 19 -- .../IRemoveFromRelationshipService.cs | 22 ++ .../Services/IResourceCommandService.cs | 8 +- .../Services/ISetRelationshipService.cs | 7 +- .../Services/IUpdateService.cs | 2 +- .../Services/JsonApiResourceService.cs | 154 +++++---- src/JsonApiDotNetCore/TypeHelper.cs | 21 +- .../ResourceInheritance/Models/Human.cs | 7 +- .../BaseJsonApiController_Tests.cs | 8 +- .../IServiceCollectionExtensionsTests.cs | 16 +- 32 files changed, 432 insertions(+), 498 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs rename src/JsonApiDotNetCore/Errors/{QueryExecutionException.cs => RepositorySaveException.cs} (55%) rename src/JsonApiDotNetCore/Errors/{RelationshipUpdateForbiddenException.cs => ToOneRelationshipUpdateForbiddenException.cs} (58%) create mode 100644 src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Services/IAddRelationshipService.cs create mode 100644 src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs delete mode 100644 src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs create mode 100644 src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 99c67290a6..b67f91eb04 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -133,9 +133,10 @@ public async Task PatchAsync(TId id, [FromBody] T resource) } [HttpPatch("{id}/relationships/{relationshipName}")] - public async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) + public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResources) { - await _resourceService.SetRelationshipAsync(id, relationshipName, relationships); + await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResources); + return Ok(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index c1c3b614ca..ae9dc35670 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -69,8 +69,8 @@ public override async Task PatchAsync(int id, [FromBody] TodoItem [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - int id, string relationshipName, [FromBody] object newValues) - => await base.PatchRelationshipAsync(id, relationshipName, newValues); + int id, string relationshipName, [FromBody] object secondaryResources) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResources); [HttpDelete("{id}")] public override async Task DeleteAsync(int id) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs b/src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs deleted file mode 100644 index d08e77af4a..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/HouseholdInsurance.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class HouseholdInsurance : Identifiable - { - public override string Id - { - get => $"{Address}-{Excess}"; - set - { - var split = value.Split('-'); - Address = split[0]; - Address = split[1]; - } - } - - [Attr] - [Required] - public string Address { get; set; } - - [Attr] - [Required] - public Excess Excess { get; set; } - - [Attr] - public decimal MonthlyFee { get; set; } - - [HasOne] - public Person Person { get; set; } - } - - public enum Excess - { - None, - Low, - High, - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 6a44fda4f0..572eb7c972 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -24,10 +24,10 @@ public CustomArticleService( IResourceFactory resourceFactory, IRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, - IResourceContextProvider provider, + IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, provider, hookExecutor) + resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, resourceContextProvider, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 0ba7d188b5..d3c489e250 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -62,22 +62,22 @@ await QueryAsync(async connection => await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new { id })); } - public Task UpdateAsync(int id, WorkItem requestResource) + public Task UpdateAsync(int id, WorkItem resourceFromRequest) { throw new NotImplementedException(); } - public Task SetRelationshipAsync(int id, string relationshipName, object newValue) + public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResources) { throw new NotImplementedException(); } - public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection newValues) + public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) { throw new NotImplementedException(); } - public Task DeleteRelationshipAsync(int id, string relationshipName, IReadOnlyCollection removalValues) + public Task RemoveFromRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index c786b200ea..5b72c06247 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -185,15 +185,15 @@ private void AddResourceLayer() private void AddRepositoryLayer() { - var intTypedResourceService = typeof(EntityFrameworkCoreRepository<>); - var openTypedResourceService = typeof(EntityFrameworkCoreRepository<,>); + var openIdResourceRepository = typeof(EntityFrameworkCoreRepository<,>); + var intIdResourceRepository = typeof(EntityFrameworkCoreRepository<>); - foreach (var partialRepositoryInterface in ServiceDiscoveryFacade.RepositoryInterfaces) + foreach (var openRepositoryInterface in ServiceDiscoveryFacade.RepositoryInterfaces) { - _services.AddScoped(partialRepositoryInterface, - partialRepositoryInterface.GetGenericArguments().Length == 2 - ? openTypedResourceService - : intTypedResourceService); + _services.AddScoped(openRepositoryInterface, + openRepositoryInterface.GetGenericArguments().Length == 2 + ? openIdResourceRepository + : intIdResourceRepository); } _services.AddScoped(); @@ -201,14 +201,15 @@ private void AddRepositoryLayer() private void AddServiceLayer() { - var intTypedResourceService = typeof(JsonApiResourceService<>); - var openTypedResourceService = typeof(JsonApiResourceService<,>); - foreach (var partialServiceInterface in ServiceDiscoveryFacade.ServiceInterfaces) + var openIdResourceService = typeof(JsonApiResourceService<,>); + var intIdResourceService = typeof(JsonApiResourceService<>); + + foreach (var openServiceInterface in ServiceDiscoveryFacade.ServiceInterfaces) { - _services.AddScoped(partialServiceInterface, - partialServiceInterface.GetGenericArguments().Length == 2 - ? openTypedResourceService - : intTypedResourceService); + _services.AddScoped(openServiceInterface, + openServiceInterface.GetGenericArguments().Length == 2 + ? openIdResourceService + : intIdResourceService); } } diff --git a/src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs b/src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs deleted file mode 100644 index b1d8e17195..0000000000 --- a/src/JsonApiDotNetCore/Configuration/MethodInfoExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Reflection; -using System.Threading.Tasks; - -namespace JsonApiDotNetCore.Configuration -{ - public static class MethodInfoExtensions - { - public static async Task InvokeAsync(this MethodInfo methodInfo, object obj, params object[] parameters) - { - if (methodInfo == null) throw new ArgumentNullException(nameof(methodInfo)); - - var task = (Task)methodInfo.Invoke(obj, parameters); - await task.ConfigureAwait(false); - var resultProperty = task.GetType().GetProperty("Result"); - return resultProperty.GetValue(task); - } - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index ddd8593f5a..10f466b12c 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -34,16 +34,16 @@ public class ServiceDiscoveryFacade typeof(IGetRelationshipService<,>), typeof(ICreateService<>), typeof(ICreateService<,>), - typeof(IAddRelationshipService<>), - typeof(IAddRelationshipService<,>), + typeof(IAddToRelationshipService<>), + typeof(IAddToRelationshipService<,>), typeof(IUpdateService<>), typeof(IUpdateService<,>), typeof(ISetRelationshipService<>), typeof(ISetRelationshipService<,>), typeof(IDeleteService<>), typeof(IDeleteService<,>), - typeof(IDeleteRelationshipService<>), - typeof(IDeleteRelationshipService<,>) + typeof(IRemoveFromRelationshipService<>), + typeof(IRemoveFromRelationshipService<,>) }; internal static readonly HashSet RepositoryInterfaces = new HashSet { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index fa9a2fb756..c75c74e2a4 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -25,11 +25,11 @@ public abstract class BaseJsonApiController : CoreJsonApiControl private readonly IGetSecondaryService _getSecondary; private readonly IGetRelationshipService _getRelationship; private readonly ICreateService _create; - private readonly IAddRelationshipService _addRelationship; + private readonly IAddToRelationshipService _addToRelationship; private readonly IUpdateService _update; private readonly ISetRelationshipService _setRelationship; private readonly IDeleteService _delete; - private readonly IDeleteRelationshipService _deleteRelationship; + private readonly IRemoveFromRelationshipService _removeFromRelationship; private readonly TraceLogWriter> _traceWriter; /// @@ -65,11 +65,11 @@ protected BaseJsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddRelationshipService addRelationship = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IDeleteRelationshipService deleteRelationship = null) + IRemoveFromRelationshipService removeFromRelationship = null) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -80,11 +80,11 @@ protected BaseJsonApiController( _getSecondary = getSecondary; _getRelationship = getRelationship; _create = create; - _addRelationship = addRelationship; + _addToRelationship = addToRelationship; _update = update; _setRelationship = setRelationship; _delete = delete; - _deleteRelationship = deleteRelationship; + _removeFromRelationship = removeFromRelationship; } /// @@ -177,13 +177,13 @@ public virtual async Task PostAsync([FromBody] TResource resource /// /// Adds resources to a to-many relationship. /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection newValues) + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipName, newValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_addRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _addRelationship.AddRelationshipAsync(id, relationshipName, newValues); + if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); + await _addToRelationship.AddRelationshipAsync(id, relationshipName, secondaryResources); return Ok(); } @@ -211,15 +211,15 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } /// - /// Updates a relationship. + /// Performs a complete replacement of a relationship. /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object newValues) + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipName, newValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _setRelationship.SetRelationshipAsync(id, relationshipName, newValues); + await _setRelationship.SetRelationshipAsync(id, relationshipName, secondaryResources); return Ok(); } @@ -240,13 +240,13 @@ public virtual async Task DeleteAsync(TId id) /// /// Removes resources from a to-many relationship. /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removalValues) + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipName, removalValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_deleteRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _deleteRelationship.DeleteRelationshipAsync(id, relationshipName, removalValues); + if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); + await _removeFromRelationship.RemoveFromRelationshipAsync(id, relationshipName, secondaryResources); return Ok(); } @@ -281,13 +281,13 @@ protected BaseJsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddRelationshipService addRelationship = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, update, - setRelationship, delete, deleteRelationship) + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, + setRelationship, delete, removeFromRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index b93e93a102..222b3c3bd4 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -33,8 +33,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection newValues) - => await base.PostRelationshipAsync(id, relationshipName, newValues); + TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResources); /// [HttpPatch("{id}")] @@ -44,8 +44,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object newValues) - => await base.PatchRelationshipAsync(id, relationshipName, newValues); + TId id, string relationshipName, [FromBody] object secondaryResources) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResources); /// [HttpDelete("{id}")] @@ -53,8 +53,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removalValues) - => await base.DeleteRelationshipAsync(id, relationshipName, removalValues); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResources); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 60a82909b9..4cce647899 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -34,13 +34,13 @@ public JsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddRelationshipService addRelationship = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory,getAll, getById, getSecondary, getRelationship, create, addRelationship, update, - setRelationship, delete, deleteRelationship) + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory,getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, + setRelationship, delete, removeFromRelationship) { } /// @@ -69,8 +69,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection newValues) - => await base.PostRelationshipAsync(id, relationshipName, newValues); + TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResources); /// [HttpPatch("{id}")] @@ -82,8 +82,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object newValues) - => await base.PatchRelationshipAsync(id, relationshipName, newValues); + TId id, string relationshipName, [FromBody] object secondaryResources) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResources); /// [HttpDelete("{id}")] @@ -91,8 +91,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection removalValues) - => await base.DeleteRelationshipAsync(id, relationshipName, removalValues); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResources); } /// @@ -115,13 +115,13 @@ public JsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddRelationshipService addRelationship = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, update, - setRelationship, delete, deleteRelationship) + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, + setRelationship, delete, removeFromRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs b/src/JsonApiDotNetCore/Errors/RepositorySaveException.cs similarity index 55% rename from src/JsonApiDotNetCore/Errors/QueryExecutionException.cs rename to src/JsonApiDotNetCore/Errors/RepositorySaveException.cs index 068c108dce..f332ae45e4 100644 --- a/src/JsonApiDotNetCore/Errors/QueryExecutionException.cs +++ b/src/JsonApiDotNetCore/Errors/RepositorySaveException.cs @@ -7,10 +7,10 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when Entity Framework Core fails executing a query. + /// The error that is thrown when the repository layer fails to save a new state. /// - public sealed class QueryExecutionException : Exception + public sealed class RepositorySaveException : Exception { - public QueryExecutionException(Exception exception) : base(exception.Message, exception) { } + public RepositorySaveException(Exception exception) : base(exception.Message, exception) { } } } diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index d8b6304d7b..1261e6401b 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -17,15 +17,15 @@ public ResourceNotFoundException(string resourceId, string resourceType) : base( Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." }) { } - public ResourceNotFoundException(Dictionary> nonExistingResources) : base( + public ResourceNotFoundException(Dictionary> nonExistingResources) : base( new Error(HttpStatusCode.NotFound) { Title = "The requested resources do not exist.", Detail = CreateErrorMessageForMultipleMissing(nonExistingResources) }) { - var pairs = nonExistingResources.ToList(); - if (pairs.Count == 1 && pairs[0].Value.Count == 1) + var pairs = nonExistingResources.ToArray(); + if (pairs.Count() == 1 && pairs[0].Value.Count == 1) { var (resourceType, value) = pairs[0]; var resourceId = value.First(); @@ -34,7 +34,7 @@ public ResourceNotFoundException(Dictionary> nonExistingRe } } - private static string CreateErrorMessageForMultipleMissing(Dictionary> missingResources) + private static string CreateErrorMessageForMultipleMissing(Dictionary> missingResources) { var errorDetailLines = missingResources.Select(p => $"{p.Key}: {string.Join(',', p.Value)}") .ToArray(); diff --git a/src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs b/src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs similarity index 58% rename from src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs rename to src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs index 5b826d1d30..3684ab2075 100644 --- a/src/JsonApiDotNetCore/Errors/RelationshipUpdateForbiddenException.cs +++ b/src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs @@ -4,11 +4,11 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when a request is received that contains an unsupported HTTP verb. + /// The error that is thrown when an attempt is made to update a to-one relationship on a to-many relationship endpoint. /// - public sealed class RelationshipUpdateForbiddenException : JsonApiException + public sealed class ToOneRelationshipUpdateForbiddenException : JsonApiException { - public RelationshipUpdateForbiddenException(string toOneRelationship) + public ToOneRelationshipUpdateForbiddenException(string toOneRelationship) : base(new Error(HttpStatusCode.Forbidden) { Title = "The request to update the relationship is forbidden.", diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs index fc6457e019..d54dedde9b 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs @@ -83,7 +83,7 @@ public IEnumerable LoadDbValues(LeftType resourceTypeForRepository, IEnumerable .GetMethod(nameof(GetWhereAndInclude), BindingFlags.NonPublic | BindingFlags.Instance) .MakeGenericMethod(resourceTypeForRepository, idType); var cast = ((IEnumerable)resources).Cast(); - var ids = TypeHelper.CopyToList(cast.Select(TypeHelper.GetResourceTypedId), idType); + var ids = TypeHelper.CopyToList(cast.Select(i => i.GetTypedId()), idType); var values = (IEnumerable)parameterizedGetWhere.Invoke(this, new object[] { ids, relationshipsToNextLayer }); if (values == null) return null; return (IEnumerable)Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(resourceTypeForRepository), TypeHelper.CopyToList(values, resourceTypeForRepository)); diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 2e5a3c32dd..838d2b281a 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -9,40 +9,18 @@ namespace JsonApiDotNetCore.Repositories { public static class DbContextExtensions { - /// - /// Determines whether or not EF is already tracking an entity of the same Type and Id - /// and returns that entity. - /// - internal static TEntity GetTrackedEntity(this DbContext context, TEntity entity) - where TEntity : class, IIdentifiable + internal static object GetTrackedIdentifiable(this DbContext context, IIdentifiable identifiable) { - if (entity == null) throw new ArgumentNullException(nameof(entity)); + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); - var entityType = entity.GetType(); + var entityType = identifiable.GetType(); var entityEntry = context.ChangeTracker .Entries() .FirstOrDefault(entry => entry.Entity.GetType() == entityType && - ((IIdentifiable) entry.Entity).StringId == entity.StringId); - - return (TEntity) entityEntry?.Entity; - } + ((IIdentifiable) entry.Entity).StringId == identifiable.StringId); - internal static object GetTrackedOrAttachCurrent(this DbContext context, IIdentifiable entity) - { - var trackedEntity = context.GetTrackedEntity(entity); - if (trackedEntity == null) - { - context.Entry(entity).State = EntityState.Unchanged; - trackedEntity = entity; - } - - return trackedEntity; - } - - internal static TResource GetTrackedOrAttachCurrent(this DbContext context, TResource entity) where TResource : IIdentifiable - { - return (TResource)GetTrackedOrAttachCurrent(context, (IIdentifiable)entity); + return entityEntry?.Entity; } /// diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 812bcaaa7e..a2d47ebf82 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -14,10 +14,28 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories { + /// + /// Implements the foundational repository implementation that uses Entity Framework Core. + /// + public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository + where TResource : class, IIdentifiable + { + public EntityFrameworkCoreRepository( + ITargetedFields targetedFields, + IDbContextResolver contextResolver, + IResourceGraph resourceGraph, + IResourceFactory resourceFactory, + IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { } + } + /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. /// @@ -118,11 +136,12 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationship in _targetedFields.Relationships) { var relationshipAssignment = relationship.GetValue(resource); - ApplyRelationshipAssignment(relationshipAssignment, relationship, resource); + await ApplyRelationshipAssignment(resource, relationship, relationshipAssignment); } _dbContext.Set().Add(resource); - await _dbContext.SaveChangesAsync(); + + await TrySave(); FlushFromCache(resource); @@ -131,79 +150,58 @@ public virtual async Task CreateAsync(TResource resource) DetachRelationships(resource); } - public async Task AddRelationshipAsync(TId id, IReadOnlyCollection newValues) + public async Task AddToRelationshipAsync(TId id, IReadOnlyCollection secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipAssignment = newValues}); - if (newValues == null) throw new ArgumentNullException(nameof(newValues)); + _traceWriter.LogMethodStart(new {id, secondaryResources}); + if (secondaryResources == null) throw new ArgumentNullException(nameof(secondaryResources)); var relationship = _targetedFields.Relationships.Single(); - var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); - ApplyRelationshipAssignment(newValues, relationship, databaseResource); + await ApplyRelationshipAssignment(localResource, relationship, secondaryResources); - try - { - await _dbContext.SaveChangesAsync(); - } - catch (DbUpdateException exception) - { - throw new QueryExecutionException(exception); - } + await TrySave(); } - public async Task SetRelationshipAsync(TId id, object newValues) + public async Task SetRelationshipAsync(TId id, object secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipAssignment = newValues}); + _traceWriter.LogMethodStart(new {id, secondaryResources}); var relationship = _targetedFields.Relationships.Single(); - var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); - LoadCurrentRelationships(databaseResource, relationship); + await LoadCurrentRelationship(localResource, relationship); - ApplyRelationshipAssignment(newValues, relationship, databaseResource); + await ApplyRelationshipAssignment(localResource, relationship, secondaryResources); - try - { - await _dbContext.SaveChangesAsync(); - } - catch (DbUpdateException exception) - { - throw new QueryExecutionException(exception); - } + await TrySave(); } /// - public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) + public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource localResource) { - _traceWriter.LogMethodStart(new {requestResource, databaseResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); + _traceWriter.LogMethodStart(new {requestResource = resourceFromRequest, localResource}); + if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); + if (localResource == null) throw new ArgumentNullException(nameof(localResource)); foreach (var attribute in _targetedFields.Attributes) { - attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + attribute.SetValue(localResource, attribute.GetValue(resourceFromRequest)); } foreach (var relationship in _targetedFields.Relationships) { // A database entity might not be tracked if it was retrieved through projection. - databaseResource = _dbContext.GetTrackedOrAttachCurrent(databaseResource); - + localResource = (TResource) GetTrackedOrAttach(localResource); + // Ensures complete replacements of relationships. - LoadCurrentRelationships(databaseResource, relationship); + await LoadCurrentRelationship(localResource, relationship); - var relationshipAssignment = relationship.GetValue(requestResource); - ApplyRelationshipAssignment(relationshipAssignment, relationship, databaseResource); + var relationshipAssignment = relationship.GetValue(resourceFromRequest); + await ApplyRelationshipAssignment(localResource, relationship, relationshipAssignment); } - try - { - await _dbContext.SaveChangesAsync(); - } - catch (DbUpdateException exception) - { - throw new QueryExecutionException(exception); - } + await TrySave(); } /// @@ -211,43 +209,29 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - var resource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + var resource = GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); _dbContext.Remove(resource); - try - { - await _dbContext.SaveChangesAsync(); - } - catch (DbUpdateException exception) - { - throw new QueryExecutionException(exception); - } + await TrySave(); } - public async Task DeleteRelationshipAsync(TId id, IReadOnlyCollection removalValues) + public async Task RemoveFromRelationshipAsync(TId id, IReadOnlyCollection secondaryResources) { - _traceWriter.LogMethodStart(new {id, removals = removalValues}); - if (removalValues == null) throw new ArgumentNullException(nameof(removalValues)); + _traceWriter.LogMethodStart(new {id, secondaryResources}); + if (secondaryResources == null) throw new ArgumentNullException(nameof(secondaryResources)); var relationship = _targetedFields.Relationships.Single(); - var databaseResource = _dbContext.GetTrackedOrAttachCurrent(_resourceFactory.CreateInstance(id)); + var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); - LoadCurrentRelationships(databaseResource, relationship); + await LoadCurrentRelationship(localResource, relationship); - var currentAssignment = ((IReadOnlyCollection) relationship.GetValue(databaseResource)); - var newAssignment = currentAssignment.Where(i => removalValues.All(r => r.StringId != i.StringId)).ToArray(); + var currentRelationshipAssignment = ((IReadOnlyCollection) relationship.GetValue(localResource)); + var newRelationshipAssignment = currentRelationshipAssignment.Where(i => secondaryResources.All(r => r.StringId != i.StringId)).ToArray(); - if (newAssignment.Length < currentAssignment.Count()) + if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count()) { - ApplyRelationshipAssignment(newAssignment, relationship, databaseResource); - try - { - await _dbContext.SaveChangesAsync(); - } - catch (DbUpdateException exception) - { - throw new QueryExecutionException(exception); - } + await ApplyRelationshipAssignment(localResource, relationship, newRelationshipAssignment); + await TrySave(); } } @@ -255,9 +239,9 @@ public async Task DeleteRelationshipAsync(TId id, IReadOnlyCollection @@ -272,29 +256,30 @@ public virtual void FlushFromCache(TResource resource) /// this into account. /// /// - private void LoadInverseRelationships(object trackedRelationshipAssignment, RelationshipAttribute relationship) + private async Task LoadInverseRelationships(object resources, RelationshipAttribute relationship) { var inverseNavigation = relationship.InverseNavigation; - if (inverseNavigation != null && trackedRelationshipAssignment != null) + + if (inverseNavigation != null) { - if (trackedRelationshipAssignment is IIdentifiable hasOneAssignment) + if (relationship is HasOneAttribute hasOneRelationship) { - var hasOneAssignmentEntry = _dbContext.Entry(hasOneAssignment); - if (IsOneToOne((HasOneAttribute)relationship)) + var entityEntry = _dbContext.Entry(resources); + + if (IsOneToOne(hasOneRelationship)) { - hasOneAssignmentEntry.Reference(inverseNavigation).Load(); + await entityEntry.Reference(inverseNavigation).LoadAsync(); } else { - hasOneAssignmentEntry.Collection(inverseNavigation).Load(); + await entityEntry.Collection(inverseNavigation).LoadAsync(); } } else if (!(relationship is HasManyThroughAttribute)) { - foreach (IIdentifiable assignmentElement in (IEnumerable) trackedRelationshipAssignment) - { - _dbContext.Entry(assignmentElement).Reference(inverseNavigation).Load(); - } + var loadTasks = ((IReadOnlyCollection)resources) + .Select(resource => _dbContext.Entry(resource).Reference(inverseNavigation).LoadAsync()); + await Task.WhenAll(loadTasks); } } } @@ -303,18 +288,21 @@ private bool IsOneToOne(HasOneAttribute relationship) { var relationshipType = relationship.RightType; var inverseNavigation = relationship.InverseNavigation; + bool inversePropertyIsEnumerable; var inverseRelationship = _resourceGraph.GetRelationships(relationshipType).FirstOrDefault(r => r.Property.Name == inverseNavigation); - if (inverseRelationship != null) + if (inverseRelationship == null) { - return inverseRelationship is HasOneAttribute; + // inverseRelationship is null when there is no RelationshipAttribute on the inverse navigation property. + // In this case we reflect on the type to figure out what kind of relationship is pointing back. + var inverseProperty = relationshipType.GetProperty(inverseNavigation).PropertyType; + inversePropertyIsEnumerable = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); + } + else + { + inversePropertyIsEnumerable = !(inverseRelationship is HasOneAttribute); } - // relationshipAttr is null when we don't put a [RelationshipAttribute] on the inverse navigation property. - // In this case we reflect on the type to figure out what kind of relationship is pointing back. - var inverseProperty = relationshipType.GetProperty(inverseNavigation).PropertyType; - var inversePropertyIsEnumerable = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); - return !inversePropertyIsEnumerable; } @@ -356,52 +344,68 @@ private void DetachRelationships(TResource resource) /// after which the reassignment `p1.todoItems = [t3, t4]` will actually /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. /// - protected void LoadCurrentRelationships(TResource databaseResource, RelationshipAttribute relationshipAttribute) + protected async Task LoadCurrentRelationship(TResource resource, RelationshipAttribute relationship) { - if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); - if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationshipAttribute is HasManyThroughAttribute throughAttribute) + var entityEntry = _dbContext.Entry(resource); + NavigationEntry navigationEntry = null; + + if (relationship is HasManyThroughAttribute hasManyThroughRelationship) { - _dbContext.Entry(databaseResource).Collection(throughAttribute.ThroughProperty.Name).Load(); + navigationEntry = entityEntry.Collection(hasManyThroughRelationship.ThroughProperty.Name); } - else if (relationshipAttribute is HasManyAttribute hasManyAttribute) + else if (relationship is HasManyAttribute hasManyRelationship) { - _dbContext.Entry(databaseResource).Collection(hasManyAttribute.Property.Name).Load(); + navigationEntry = entityEntry.Collection(hasManyRelationship.Property.Name); } - else if (relationshipAttribute is HasOneAttribute hasOneAttribute) + else if (relationship is HasOneAttribute hasOneRelationship) { - if (GetForeignKeyProperty(hasOneAttribute) == null) - { // If the primary resource is the dependent side of a to-one relationship, there is no - // need to load the relationship because we can just set the FK. - _dbContext.Entry(databaseResource).Reference(hasOneAttribute.Property.Name).Load(); + if (GetForeignKey(hasOneRelationship) == null) + { // If the primary resource is the dependent side of a to-one relationship, there can be no + // FK violations resulting from a the implicit removal. + navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); } } + + await (navigationEntry?.LoadAsync() ?? Task.CompletedTask); } - private void ApplyRelationshipAssignment(object relationshipAssignment, RelationshipAttribute relationship, TResource targetResource = null) + private async Task ApplyRelationshipAssignment(TResource localResource, RelationshipAttribute relationship, object relationshipAssignment) { // Ensures the new relationship assignment will not result entities being tracked more than once. - var trackedRelationshipAssignment = GetTrackedRelationshipValue(relationship, relationshipAssignment); - - // Ensures successful handling of implicit removals of relationships. - LoadInverseRelationships(trackedRelationshipAssignment, relationship); - - var foreignKey = GetForeignKeyProperty(relationship); - if (foreignKey != null) + object trackedRelationshipAssignment = null; + + if (relationshipAssignment != null) + { + trackedRelationshipAssignment = GetTrackedRelationshipAssignment(relationshipAssignment, relationship.Property.PropertyType); + + // Ensures successful handling of implicit removals of relationships. + await LoadInverseRelationships(trackedRelationshipAssignment, relationship); + } + + if (relationship is HasOneAttribute) { - var foreignKeyValue = trackedRelationshipAssignment == null ? null : TypeHelper.GetResourceTypedId((IIdentifiable) trackedRelationshipAssignment); - foreignKey.SetValue(targetResource, foreignKeyValue); - if (_dbContext.Entry(targetResource).State != EntityState.Detached) + object secondaryResourceId = null; + + if (trackedRelationshipAssignment is IIdentifiable secondaryResource) + { + secondaryResourceId = secondaryResource.GetTypedId(); + } + + var foreignKey = GetForeignKey(relationship); + if (foreignKey != null) { - _dbContext.Entry(targetResource).State = EntityState.Modified; + foreignKey.SetValue(localResource, secondaryResourceId); + _dbContext.Entry(localResource).State = EntityState.Modified; } } - relationship.SetValue(targetResource, trackedRelationshipAssignment, _resourceFactory); + relationship.SetValue(localResource, trackedRelationshipAssignment, _resourceFactory); } - private object GetTrackedRelationshipValue(RelationshipAttribute relationship, object relationshipAssignment) + private object GetTrackedRelationshipAssignment(object relationshipAssignment, Type relationshipType) { object trackedRelationshipAssignment; @@ -409,31 +413,31 @@ private object GetTrackedRelationshipValue(RelationshipAttribute relationship, o { trackedRelationshipAssignment = null; } - else if (relationshipAssignment is IIdentifiable hasOneAssignment) + else if (relationshipAssignment is IIdentifiable hasOneValue) { - trackedRelationshipAssignment = _dbContext.GetTrackedOrAttachCurrent(hasOneAssignment); + trackedRelationshipAssignment = GetTrackedOrAttach(hasOneValue); } else { - var hasManyAssignment = ((IReadOnlyCollection) relationshipAssignment); - var collection = new object[hasManyAssignment.Count()]; + var hasManyValue = ((IReadOnlyCollection)relationshipAssignment); + var trackedHasManyValues = new object[hasManyValue.Count()]; - for (int i = 0; i < hasManyAssignment.Count; i++) + for (int i = 0; i < hasManyValue.Count; i++) { - var trackedHasManyElement = _dbContext.GetTrackedOrAttachCurrent(hasManyAssignment.ElementAt(i)); + var trackedHasManyValue = GetTrackedOrAttach(hasManyValue.ElementAt(i)); // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. - var conversionTarget = trackedHasManyElement.GetType(); - collection[i] = Convert.ChangeType(trackedHasManyElement, conversionTarget); + var conversionTarget = trackedHasManyValue.GetType(); + trackedHasManyValues[i] = Convert.ChangeType(trackedHasManyValue, conversionTarget); } - trackedRelationshipAssignment = TypeHelper.CopyToTypedCollection(collection, relationship.Property.PropertyType); + trackedRelationshipAssignment = TypeHelper.CopyToTypedCollection(trackedHasManyValues, relationshipType); } return trackedRelationshipAssignment; } - - private PropertyInfo GetForeignKeyProperty(RelationshipAttribute relationship) + + private PropertyInfo GetForeignKey(RelationshipAttribute relationship) { PropertyInfo foreignKey = null; @@ -442,32 +446,40 @@ private PropertyInfo GetForeignKeyProperty(RelationshipAttribute relationship) var entityMetadata = _dbContext.Model.FindEntityType(typeof(TResource)); var foreignKeyMetadata = entityMetadata.FindNavigation(relationship.Property.Name).ForeignKey; foreignKey = foreignKeyMetadata.Properties[0].PropertyInfo; + + if (foreignKey?.DeclaringType != typeof(TResource)) + { + foreignKey = null; + } + _foreignKeyCache.TryAdd(relationship, foreignKey); } + + return foreignKey; + } - if (foreignKey == null || foreignKey.DeclaringType != typeof(TResource)) + private IIdentifiable GetTrackedOrAttach(IIdentifiable resource) + { + var trackedResource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); + if (trackedResource == null) { - return null; + _dbContext.Entry(resource).State = EntityState.Unchanged; + trackedResource = resource; } - - return foreignKey; + + return trackedResource; } - } - /// - /// Implements the foundational repository implementation that uses Entity Framework Core. - /// - public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository - where TResource : class, IIdentifiable - { - public EntityFrameworkCoreRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, - IResourceGraph resourceGraph, - IResourceFactory resourceFactory, - IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { } + private async Task TrySave() + { + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new RepositorySaveException(exception); + } + } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 64b537c67e..60c003412a 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -25,36 +25,32 @@ public interface IResourceWriteRepository Task CreateAsync(TResource resource); /// - /// Adds a value to a relationship collection in the underlying data store. + /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddRelationshipAsync(TId id, IReadOnlyCollection newValues); + Task AddToRelationshipAsync(TId id, IReadOnlyCollection secondaryResources); /// - /// Updates an existing resource in the underlying data store. + /// Updates the attributes and relationships of an existing resource in the underlying data store. /// - /// The (partial) resource coming from the request body. - /// The resource as stored in the database before the update. - Task UpdateAsync(TResource requestResource, TResource databaseResource); + Task UpdateAsync(TResource resourceFromRequest, TResource localResource); /// - /// Performs a complete replacement of a relationship in the underlying data store. + /// Performs a complete replacement of the value(s) of a relationship in the underlying data store. /// - Task SetRelationshipAsync(TId id, object newValues); + Task SetRelationshipAsync(TId id, object secondaryResources); /// /// Deletes a resource from the underlying data store. /// - /// Identifier for the resource to delete. - /// true if the resource was deleted; false is the resource did not exist. Task DeleteAsync(TId id); /// - /// Removes a value from a relationship collection in the underlying data store. + /// Removes resources from a to-many relationship in the underlying data store. /// - Task DeleteRelationshipAsync(TId id, IReadOnlyCollection removalValues); + Task RemoveFromRelationshipAsync(TId id, IReadOnlyCollection secondaryResources); /// - /// Ensures that the next time this resource is requested, it is re-fetched from the underlying data store. + /// Ensures that the next time a given resource is requested, it is re-fetched from the underlying data store. /// void FlushFromCache(TResource resource); } diff --git a/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs index 724b7ba102..b868647944 100644 --- a/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs @@ -45,8 +45,8 @@ public async Task> GetResourcesByIdAsync(Type resourc { var resourceContext = _provider.GetResourceContext(resourceType); var (getByIdMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); - - var resources = await getByIdMethod.InvokeAsync(this, ids, repository, resourceContext); + + var resources = await InvokeAsync(getByIdMethod, this, new [] { ids, repository, resourceContext }); return (IEnumerable) resources; } @@ -57,7 +57,7 @@ public async Task> GetResourcesByIdAsync(Type resourc if (!_parameterizedMethodRepositoryCache.TryGetValue(resourceType, out var accessorPair)) { var parameterizedMethod = _openGetByIdMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); - + var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); var repository = _serviceProvider.GetRequiredService(repositoryType); @@ -69,7 +69,7 @@ public async Task> GetResourcesByIdAsync(Type resourc } private async Task> GetById( - IReadOnlyCollection ids, + IEnumerable ids, IResourceReadRepository repository, ResourceContext resourceContext) where TResource : class, IIdentifiable @@ -85,7 +85,7 @@ private async Task> GetById( }; // Only apply projection when there is no resource inheritance. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844. - // We can leave it out because the projection here is an optimization, not a functional requirement. + // We can leave it out because the projection here is just an optimization if (!resourceContext.ResourceType.IsAbstract) { var projection = new Dictionary {{idAttribute, null}}; @@ -94,5 +94,13 @@ private async Task> GetById( return await repository.GetAsync(queryLayer); } + + private async Task InvokeAsync(MethodInfo methodInfo, object target, object[] parameters) + { + dynamic task = methodInfo.Invoke(target, parameters); + await task; + + return task.GetAwaiter().GetResult(); + } } } diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs new file mode 100644 index 0000000000..a77d8d422a --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs @@ -0,0 +1,18 @@ +using System; +using System.Reflection; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Resources +{ + public static class IIdentifiableExtensions + { + internal static object GetTypedId(this IIdentifiable identifiable) + { + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + + PropertyInfo property = identifiable.GetType().GetProperty(nameof(Identifiable.Id)); + + return property.GetValue(identifiable); + } + } +} diff --git a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs deleted file mode 100644 index 376662f24c..0000000000 --- a/src/JsonApiDotNetCore/Services/IAddRelationshipService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Services -{ - /// - public interface IAddRelationshipService : IAddRelationshipService - where TResource : class, IIdentifiable { } - - /// - public interface IAddRelationshipService where TResource : class, IIdentifiable - { - /// - /// Handles a json:api request to add resources to a to-many relationship. - /// - Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection newValues); - } -} diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs new file mode 100644 index 0000000000..a21ff7d4db --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IAddToRelationshipService : IAddToRelationshipService + where TResource : class, IIdentifiable { } + + /// + public interface IAddToRelationshipService where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to add resources to a to-many relationship. + /// + /// The identifier of the primary resource. + /// The relationship to add resources to. + /// The resources to add to the relationship. + Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources); + } +} diff --git a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs b/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs deleted file mode 100644 index e2675b8b82..0000000000 --- a/src/JsonApiDotNetCore/Services/IDeleteRelationshipService.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Services -{ - /// - public interface IDeleteRelationshipService : IDeleteRelationshipService - where TResource : class, IIdentifiable { } - - /// - public interface IDeleteRelationshipService where TResource : class, IIdentifiable - { - /// - /// Handles a json:api request to remove resources from a to-many relationship. - /// - Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removalValues); - } -} diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs new file mode 100644 index 0000000000..226a77fb80 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IRemoveFromRelationshipService : IRemoveFromRelationshipService + where TResource : class, IIdentifiable { } + + /// + public interface IRemoveFromRelationshipService where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to remove resources from a to-many relationship. + /// + /// The identifier of the primary resource. + /// The relationship to remove resources from. + /// The resources to remove from the relationship. + Task RemoveFromRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources); + } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 656089c031..a769f90f4c 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -8,11 +8,11 @@ namespace JsonApiDotNetCore.Services /// The resource type. public interface IResourceCommandService : ICreateService, - IAddRelationshipService, + IAddToRelationshipService, IUpdateService, ISetRelationshipService, IDeleteService, - IDeleteRelationshipService, + IRemoveFromRelationshipService, IResourceCommandService where TResource : class, IIdentifiable { } @@ -24,11 +24,11 @@ public interface IResourceCommandService : /// The resource identifier type. public interface IResourceCommandService : ICreateService, - IAddRelationshipService, + IAddToRelationshipService, IUpdateService, ISetRelationshipService, IDeleteService, - IDeleteRelationshipService + IRemoveFromRelationshipService where TResource : class, IIdentifiable { } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 56408280b4..014c83fc23 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -12,8 +12,11 @@ public interface ISetRelationshipService : ISetRelationshipService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to update an existing relationship. + /// Handles a json:api request to perform a complete replacement of the value of a relationship. /// - Task SetRelationshipAsync(TId id, string relationshipName, object newValues); + /// The identifier of the primary resource. + /// The relationship for which to perform a complete replacement. + /// The resources to perform the complete replacement with. + Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResources); } } diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index c34b8ed511..7afd8e1b91 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -15,6 +15,6 @@ public interface IUpdateService /// /// Handles a json:api request to update an existing resource. /// - Task UpdateAsync(TId id, TResource resource); + Task UpdateAsync(TId id, TResource resourceFromRequest); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index e8612ab617..b6831eb63d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -231,10 +231,10 @@ public virtual async Task CreateAsync(TResource resource) { await _repository.CreateAsync(resource); } - catch (QueryExecutionException) + catch (RepositorySaveException) { - var nonNullAssignments = GetNonNullAssignments(resource); - await AssertValuesOfRelationshipAssignmentExistAsync(nonNullAssignments); + var relationshipsWithValues = GetPopulatedRelationships(resource); + await AssertValuesOfRelationshipAssignmentExistAsync(relationshipsWithValues); throw; } @@ -252,26 +252,26 @@ public virtual async Task CreateAsync(TResource resource) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection newValues) + public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources) { - _traceWriter.LogMethodStart(new { id, newValues }); + _traceWriter.LogMethodStart(new { id, secondaryResources }); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); - if (newValues.Any()) + if (secondaryResources.Any()) { try { - await _repository.AddRelationshipAsync(id, newValues); + await _repository.AddToRelationshipAsync(id, secondaryResources); } - catch (QueryExecutionException) + catch (RepositorySaveException) { var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); - var assignment = new RelationshipAssignment(_request.Relationship, newValues); + var assignment = new Dictionary { { _request.Relationship, secondaryResources } }; await AssertValuesOfRelationshipAssignmentExistAsync(assignment); throw; @@ -281,53 +281,52 @@ public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnl /// // triggered by PATCH /articles/{id} - public virtual async Task UpdateAsync(TId id, TResource requestResource) + public virtual async Task UpdateAsync(TId id, TResource resourceFromRequest) { - _traceWriter.LogMethodStart(new {id, requestResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + _traceWriter.LogMethodStart(new {id, resourceFromRequest}); + if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); - TResource databaseResource = await GetPrimaryResourceById(id, false); + TResource localResource = await GetPrimaryResourceById(id, false); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); - _resourceChangeTracker.SetRequestedAttributeValues(requestResource); + _resourceChangeTracker.SetInitiallyStoredAttributeValues(localResource); + _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); if (_hookExecutor != null) { - requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); + resourceFromRequest = _hookExecutor.BeforeUpdate(AsList(resourceFromRequest), ResourcePipeline.Patch).Single(); } try { - await _repository.UpdateAsync(requestResource, databaseResource); + await _repository.UpdateAsync(resourceFromRequest, localResource); } - catch (QueryExecutionException) + catch (RepositorySaveException) { - var assignments = GetNonNullAssignments(requestResource); + var assignments = GetPopulatedRelationships(resourceFromRequest); await AssertValuesOfRelationshipAssignmentExistAsync(assignments); - - + throw; } if (_hookExecutor != null) { - _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(databaseResource), ResourcePipeline.Patch); + _hookExecutor.AfterUpdate(AsList(localResource), ResourcePipeline.Patch); + _hookExecutor.OnReturn(AsList(localResource), ResourcePipeline.Patch); } - _repository.FlushFromCache(databaseResource); - TResource afterResource = await GetPrimaryResourceById(id, false); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResource); + _repository.FlushFromCache(localResource); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, false); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterResource : null; + return hasImplicitChanges ? afterResourceFromDatabase : null; } /// // triggered by PATCH /articles/{id}/relationships/{relationshipName} - public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object newValues) + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipName, newValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -343,9 +342,9 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, try { - await _repository.SetRelationshipAsync(id, newValues); + await _repository.SetRelationshipAsync(id, secondaryResources); } - catch (QueryExecutionException) + catch (RepositorySaveException) { if (primaryResource == null) { @@ -353,9 +352,9 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertPrimaryResourceExists(primaryResource); } - if (newValues != null) + if (secondaryResources != null) { - var assignment = new RelationshipAssignment(_request.Relationship, newValues); + var assignment = new Dictionary { { _request.Relationship, secondaryResources } }; await AssertValuesOfRelationshipAssignmentExistAsync(assignment); } @@ -387,7 +386,7 @@ public virtual async Task DeleteAsync(TId id) { await _repository.DeleteAsync(id); } - catch (QueryExecutionException) + catch (RepositorySaveException) { succeeded = false; resource = await GetProjectedPrimaryResourceById(id); @@ -403,9 +402,9 @@ public virtual async Task DeleteAsync(TId id) /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public async Task DeleteRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection removalValues) + public async Task RemoveFromRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources) { - _traceWriter.LogMethodStart(new {id, relationshipName, removalValues}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -413,9 +412,9 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IRead try { - await _repository.DeleteRelationshipAsync(id, removalValues); + await _repository.RemoveFromRelationshipAsync(id, secondaryResources); } - catch (QueryExecutionException) + catch (RepositorySaveException) { var resource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(resource); @@ -424,19 +423,6 @@ public async Task DeleteRelationshipAsync(TId id, string relationshipName, IRead } } - private readonly struct RelationshipAssignment - { - public RelationshipAttribute Relationship { get; } - - public object Value { get; } - - public RelationshipAssignment(RelationshipAttribute relationship, object value) - { - Relationship = relationship; - Value = value; - } - } - private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) { var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); @@ -462,30 +448,38 @@ private async Task GetProjectedPrimaryResourceById(TId id) // https://github.com/dotnet/efcore/issues/20502 queryLayer.Projection = new Dictionary { { idAttribute, null } }; } - - + var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); return primaryResource; } - - private RelationshipAssignment[] GetNonNullAssignments(TResource requestResource) + + private Dictionary GetPopulatedRelationships(TResource requestResource) { var assignments = _targetedFields.Relationships - .Select(relationship => new RelationshipAssignment (relationship ,relationship.GetValue(requestResource))) - .Where(p => - { - return p.Value switch - { - IIdentifiable hasOneAssignment => true, - IReadOnlyCollection hasManyAssignment => hasManyAssignment.Any(), - _ => false - }; - }).ToArray(); + .Select(relationship => (Relationship: relationship, Value: relationship.GetValue(requestResource))) + .Where(RelationshipIsPopulated) + .ToDictionary(r => r.Relationship, r => r.Value); return assignments; } + private bool RelationshipIsPopulated((RelationshipAttribute Relationship, object Value) p) + { + if (p.Value is IIdentifiable hasOneValue) + { + return true; + } + else if (p.Value is IReadOnlyCollection hasManyValues) + { + return hasManyValues.Any(); + } + else + { + return false; + } + } + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -506,47 +500,45 @@ private void AssertRelationshipExists(string relationshipName) private void AssertRelationshipIsToMany() { var relationship = _request.Relationship; - if (!(relationship is HasManyAttribute)) + if (relationship is HasOneAttribute); { - throw new RelationshipUpdateForbiddenException(relationship.PublicName); + throw new ToOneRelationshipUpdateForbiddenException(relationship.PublicName); } } - private async Task AssertValuesOfRelationshipAssignmentExistAsync(params RelationshipAssignment[] nonNullRelationshipAssignments) + private async Task AssertValuesOfRelationshipAssignmentExistAsync(Dictionary nonNullRelationshipAssignments) { - var nonExistingResources = new Dictionary>(); + var missingResources = new Dictionary>(); + foreach (var assignment in nonNullRelationshipAssignments) { IReadOnlyCollection identifiers; if (assignment.Value is IIdentifiable identifiable) { - identifiers = new [] { TypeHelper.GetResourceStringId(identifiable) }; + identifiers = new [] { identifiable.GetTypedId().ToString() }; } else { - identifiers = ((IReadOnlyCollection) assignment.Value).Select(TypeHelper.GetResourceStringId).ToArray(); + identifiers = ((IEnumerable)assignment.Value) + .Select(i => i.GetTypedId().ToString()) + .ToArray(); } - var resources = await _repositoryAccessor.GetResourcesByIdAsync(assignment.Relationship.RightType, identifiers); - var missing = identifiers.Where(id => resources.All(r => TypeHelper.GetResourceStringId(r) != id)).ToArray(); + var resources = await _repositoryAccessor.GetResourcesByIdAsync(assignment.Key.RightType, identifiers); + var missing = identifiers.Where(id => resources.All(r => r.GetTypedId().ToString() != id)).ToArray(); if (missing.Any()) { - nonExistingResources.Add(_provider.GetResourceContext(assignment.Relationship.RightType).PublicName, missing.ToArray()); + missingResources.Add(_provider.GetResourceContext(assignment.Key.RightType).PublicName, missing.ToArray()); } } - if (nonExistingResources.Any()) + if (missingResources.Any()) { - throw new ResourceNotFoundException(nonExistingResources); + throw new ResourceNotFoundException(missingResources); } } - private IncludeExpression IncludeRelationshipExpression(RelationshipAttribute relationship) - { - return new IncludeExpression(new[] { new IncludeElementExpression(relationship) }); - } - private List AsList(TResource resource) { return new List { resource }; diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 6d11ca14e7..37e4dbb535 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -289,28 +289,9 @@ public static object ConvertStringIdToTypedId(Type resourceType, string stringId { var tempResource = (IIdentifiable)resourceFactory.CreateInstance(resourceType); tempResource.StringId = stringId; - return GetResourceTypedId(tempResource); - } - - /// - /// Gets the string value of the id of an identifiable through the typed value. This can be useful when dealing with - /// obfuscated IDs. - /// - public static string GetResourceStringId(IIdentifiable identifiable) - { - return GetResourceTypedId(identifiable).ToString(); + return tempResource.GetTypedId(); } - /// - /// Gets the typed value of the id of an identifiable. - /// - public static object GetResourceTypedId(IIdentifiable resource) - { - if (resource == null) throw new ArgumentNullException(nameof(resource)); - PropertyInfo property = resource.GetType().GetProperty(nameof(Identifiable.Id)); - - return property.GetValue(resource); - } /// /// Sets the typed value of the id of an identifiable. diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs index 01f8136641..f719f0f9b2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs @@ -9,10 +9,9 @@ public abstract class Human : Identifiable { [Attr] public bool Retired { get; set; } - - [HasOne] - public HealthInsurance HealthInsurance { get; set; } - + + [HasOne] public HealthInsurance HealthInsurance { get; set; } + [HasMany] public ICollection Parents { get; set; } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 19802862ab..488f263f07 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -40,13 +40,13 @@ public ResourceController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddRelationshipService addRelationship = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IDeleteRelationshipService deleteRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addRelationship, - update, setRelationship, delete, deleteRelationship) + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, + update, setRelationship, delete, removeFromRelationship) { } } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 47add8b37b..5c3ad8ba19 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -165,10 +165,10 @@ private class IntResourceService : IResourceService public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(int id, string relationshipName, object newValue) => throw new NotImplementedException(); - public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection newValues) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(int id, string relationshipName, IReadOnlyCollection removalValues) => throw new NotImplementedException(); + public Task UpdateAsync(int id, IntResource resourceFromRequest) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResources) => throw new NotImplementedException(); + public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); + public Task RemoveFromRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -179,10 +179,10 @@ private class GuidResourceService : IResourceService public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(Guid id, string relationshipName, object newValue) => throw new NotImplementedException(); - public Task AddRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection newValues) => throw new NotImplementedException(); - public Task DeleteRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection removalValues) => throw new NotImplementedException(); + public Task UpdateAsync(Guid id, GuidResource resourceFromRequest) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResources) => throw new NotImplementedException(); + public Task AddRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); + public Task RemoveFromRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); } From 7236fb45f3edde424a569ff81e3487e624177765 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 12 Oct 2020 10:34:17 +0200 Subject: [PATCH 033/240] Refactored AddRepositoryLayer/AddServiceLayer --- .../JsonApiApplicationBuilder.cs | 41 ++++++++----------- .../Configuration/ServiceDiscoveryFacade.cs | 4 +- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 5b72c06247..1e3b04ba00 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -174,42 +174,37 @@ private void AddMiddlewareLayer() private void AddResourceLayer() { - _services.AddScoped(typeof(IResourceDefinition<>), typeof(JsonApiResourceDefinition<>)); - _services.AddScoped(typeof(IResourceDefinition<,>), typeof(JsonApiResourceDefinition<,>)); - _services.AddScoped(); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, + typeof(JsonApiResourceDefinition<>), typeof(JsonApiResourceDefinition<,>)); + _services.AddScoped(); _services.AddScoped(); - _services.AddSingleton(sp => sp.GetRequiredService()); } private void AddRepositoryLayer() { - var openIdResourceRepository = typeof(EntityFrameworkCoreRepository<,>); - var intIdResourceRepository = typeof(EntityFrameworkCoreRepository<>); - - foreach (var openRepositoryInterface in ServiceDiscoveryFacade.RepositoryInterfaces) - { - _services.AddScoped(openRepositoryInterface, - openRepositoryInterface.GetGenericArguments().Length == 2 - ? openIdResourceRepository - : intIdResourceRepository); - } - + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, + typeof(EntityFrameworkCoreRepository<>), typeof(EntityFrameworkCoreRepository<,>)); + _services.AddScoped(); } private void AddServiceLayer() { - var openIdResourceService = typeof(JsonApiResourceService<,>); - var intIdResourceService = typeof(JsonApiResourceService<>); - - foreach (var openServiceInterface in ServiceDiscoveryFacade.ServiceInterfaces) + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, + typeof(JsonApiResourceService<>), typeof(JsonApiResourceService<,>)); + } + + private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type intImplementation, Type implementation) + { + foreach (var openGenericInterface in openGenericInterfaces) { - _services.AddScoped(openServiceInterface, - openServiceInterface.GetGenericArguments().Length == 2 - ? openIdResourceService - : intIdResourceService); + var implementationType = openGenericInterface.GetGenericArguments().Length == 1 + ? intImplementation + : implementation; + + _services.AddScoped(openGenericInterface, implementationType); } } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 10f466b12c..5d9a40d9bf 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -55,7 +55,7 @@ public class ServiceDiscoveryFacade typeof(IResourceReadRepository<,>) }; - private static readonly HashSet _resourceDefinitionInterfaces = new HashSet { + internal static readonly HashSet ResourceDefinitionInterfaces = new HashSet { typeof(IResourceDefinition<>), typeof(IResourceDefinition<,>) }; @@ -182,7 +182,7 @@ private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescr private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) { - foreach (var resourceDefinitionInterface in _resourceDefinitionInterfaces) + foreach (var resourceDefinitionInterface in ResourceDefinitionInterfaces) { RegisterImplementations(assembly, resourceDefinitionInterface, resourceDescriptor); } From fcc42b74a42bf72101c45795a2965e1671669282 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 12 Oct 2020 10:37:41 +0200 Subject: [PATCH 034/240] fix broken tests --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index b6831eb63d..6d986c55d9 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -500,7 +500,7 @@ private void AssertRelationshipExists(string relationshipName) private void AssertRelationshipIsToMany() { var relationship = _request.Relationship; - if (relationship is HasOneAttribute); + if (relationship is HasOneAttribute) { throw new ToOneRelationshipUpdateForbiddenException(relationship.PublicName); } From 5ea2ca58d00ee81c4034a71d7af86acea9c78585 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 12 Oct 2020 10:37:46 +0200 Subject: [PATCH 035/240] fix logging --- .../Repositories/EntityFrameworkCoreRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a2d47ebf82..3798add3b7 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -180,7 +180,7 @@ public async Task SetRelationshipAsync(TId id, object secondaryResources) /// public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource localResource) { - _traceWriter.LogMethodStart(new {requestResource = resourceFromRequest, localResource}); + _traceWriter.LogMethodStart(new {resourceFromRequest, localResource}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (localResource == null) throw new ArgumentNullException(nameof(localResource)); From 15c52a26fe5e5a4c94f75c20e119c4e0846415a9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 12 Oct 2020 12:04:55 +0200 Subject: [PATCH 036/240] relationship renames and documentation --- .../Controllers/TodoItemsTestController.cs | 4 +- .../Services/WorkItemService.cs | 6 +-- .../Controllers/BaseJsonApiController.cs | 43 +++++++++++++------ .../Controllers/JsonApiCommandController.cs | 12 +++--- .../Controllers/JsonApiController.cs | 12 +++--- .../EntityFrameworkCoreRepository.cs | 22 +++++----- .../Repositories/IResourceWriteRepository.cs | 10 ++--- .../Services/IAddToRelationshipService.cs | 4 +- .../Services/ICreateService.cs | 2 +- .../IRemoveFromRelationshipService.cs | 4 +- .../Services/ISetRelationshipService.cs | 6 +-- .../Services/IUpdateService.cs | 2 +- .../Services/JsonApiResourceService.cs | 26 +++++------ .../IServiceCollectionExtensionsTests.cs | 12 +++--- 14 files changed, 91 insertions(+), 74 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index ae9dc35670..e53ce44723 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -69,8 +69,8 @@ public override async Task PatchAsync(int id, [FromBody] TodoItem [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - int id, string relationshipName, [FromBody] object secondaryResources) - => await base.PatchRelationshipAsync(id, relationshipName, secondaryResources); + int id, string relationshipName, [FromBody] object secondaryResourceIds) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds); [HttpDelete("{id}")] public override async Task DeleteAsync(int id) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index d3c489e250..07280eadb4 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -67,17 +67,17 @@ public Task UpdateAsync(int id, WorkItem resourceFromRequest) throw new NotImplementedException(); } - public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResources) + public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) { throw new NotImplementedException(); } - public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) + public Task AddToToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) { throw new NotImplementedException(); } - public Task RemoveFromRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) + public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index c75c74e2a4..f85e9ba1df 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -135,6 +135,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati /// /// Gets a single resource relationship. /// Example: GET /articles/1/relationships/author HTTP/1.1 + /// Example: GET /articles/1/relationships/revisions HTTP/1.1 /// public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { @@ -148,7 +149,8 @@ public virtual async Task GetRelationshipAsync(TId id, string rel } /// - /// Creates a new resource. + /// Creates a new resource with attributes, relationships or both. + /// Example: POST /articles HTTP/1.1 /// public virtual async Task PostAsync([FromBody] TResource resource) { @@ -176,20 +178,25 @@ public virtual async Task PostAsync([FromBody] TResource resource /// /// Adds resources to a to-many relationship. + /// Example: POST /articles/1/revisions HTTP/1.1 /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) + /// The identifier of the primary resource. + /// The relationship to add resources to. + /// The set of resources to add to the relationship. + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _addToRelationship.AddRelationshipAsync(id, relationshipName, secondaryResources); + await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); return Ok(); } /// - /// Updates an existing resource. May contain a partial set of attributes. + /// Updates an existing resource with attributes, relationships or both. May contain a partial set of attributes. + /// Example: PATCH /articles/1 HTTP/1.1 /// public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) { @@ -211,21 +218,27 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } /// - /// Performs a complete replacement of a relationship. + /// Performs a complete replacement of a relationship on an existing resource. + /// Example: PATCH /articles/1/relationships/author HTTP/1.1 + /// Example: PATCH /articles/1/relationships/revisions HTTP/1.1 /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResources) + /// The identifier of the primary resource. + /// The relationship for which to perform a complete replacement. + /// The resources to assign to the relationship. + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _setRelationship.SetRelationshipAsync(id, relationshipName, secondaryResources); + await _setRelationship.SetRelationshipAsync(id, relationshipName, secondaryResourceIds); return Ok(); } /// - /// Deletes a resource. + /// Deletes an existing resource. + /// Example: DELETE /articles/1 HTTP/1.1 /// public virtual async Task DeleteAsync(TId id) { @@ -239,14 +252,18 @@ public virtual async Task DeleteAsync(TId id) /// /// Removes resources from a to-many relationship. + /// Example: DELETE /articles/1/relationships/revisions HTTP/1.1 /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) + /// The identifier of the primary resource. + /// The relationship to remove resources from. + /// The resources to remove from the relationship. + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _removeFromRelationship.RemoveFromRelationshipAsync(id, relationshipName, secondaryResources); + await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); return Ok(); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 222b3c3bd4..18bb9a6aab 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -33,8 +33,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) - => await base.PostRelationshipAsync(id, relationshipName, secondaryResources); + TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); /// [HttpPatch("{id}")] @@ -44,8 +44,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object secondaryResources) - => await base.PatchRelationshipAsync(id, relationshipName, secondaryResources); + TId id, string relationshipName, [FromBody] object secondaryResourceIds) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds); /// [HttpDelete("{id}")] @@ -53,8 +53,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) - => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResources); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 4cce647899..866d992b14 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -69,8 +69,8 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) - => await base.PostRelationshipAsync(id, relationshipName, secondaryResources); + TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); /// [HttpPatch("{id}")] @@ -82,8 +82,8 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object secondaryResources) - => await base.PatchRelationshipAsync(id, relationshipName, secondaryResources); + TId id, string relationshipName, [FromBody] object secondaryResourceIds) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds); /// [HttpDelete("{id}")] @@ -91,8 +91,8 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResources) - => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResources); + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } /// diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3798add3b7..3584b648a9 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -150,29 +150,29 @@ public virtual async Task CreateAsync(TResource resource) DetachRelationships(resource); } - public async Task AddToRelationshipAsync(TId id, IReadOnlyCollection secondaryResources) + public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, secondaryResources}); - if (secondaryResources == null) throw new ArgumentNullException(nameof(secondaryResources)); + _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); var relationship = _targetedFields.Relationships.Single(); var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); - await ApplyRelationshipAssignment(localResource, relationship, secondaryResources); + await ApplyRelationshipAssignment(localResource, relationship, secondaryResourceIds); await TrySave(); } - public async Task SetRelationshipAsync(TId id, object secondaryResources) + public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, secondaryResources}); + _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); var relationship = _targetedFields.Relationships.Single(); var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); await LoadCurrentRelationship(localResource, relationship); - await ApplyRelationshipAssignment(localResource, relationship, secondaryResources); + await ApplyRelationshipAssignment(localResource, relationship, secondaryResourceIds); await TrySave(); } @@ -215,10 +215,10 @@ public virtual async Task DeleteAsync(TId id) await TrySave(); } - public async Task RemoveFromRelationshipAsync(TId id, IReadOnlyCollection secondaryResources) + public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, secondaryResources}); - if (secondaryResources == null) throw new ArgumentNullException(nameof(secondaryResources)); + _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); var relationship = _targetedFields.Relationships.Single(); var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); @@ -226,7 +226,7 @@ public async Task RemoveFromRelationshipAsync(TId id, IReadOnlyCollection) relationship.GetValue(localResource)); - var newRelationshipAssignment = currentRelationshipAssignment.Where(i => secondaryResources.All(r => r.StringId != i.StringId)).ToArray(); + var newRelationshipAssignment = currentRelationshipAssignment.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToArray(); if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count()) { diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 60c003412a..530d1af357 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -27,7 +27,7 @@ public interface IResourceWriteRepository /// /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddToRelationshipAsync(TId id, IReadOnlyCollection secondaryResources); + Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds); /// /// Updates the attributes and relationships of an existing resource in the underlying data store. @@ -35,19 +35,19 @@ public interface IResourceWriteRepository Task UpdateAsync(TResource resourceFromRequest, TResource localResource); /// - /// Performs a complete replacement of the value(s) of a relationship in the underlying data store. + /// Performs a complete replacement of the relationship in the underlying data store. /// - Task SetRelationshipAsync(TId id, object secondaryResources); + Task SetRelationshipAsync(TId id, object secondaryResourceIds); /// - /// Deletes a resource from the underlying data store. + /// Deletes an existing resource from the underlying data store. /// Task DeleteAsync(TId id); /// /// Removes resources from a to-many relationship in the underlying data store. /// - Task RemoveFromRelationshipAsync(TId id, IReadOnlyCollection secondaryResources); + Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds); /// /// Ensures that the next time a given resource is requested, it is re-fetched from the underlying data store. diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index a21ff7d4db..43a8ce5fe4 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -16,7 +16,7 @@ public interface IAddToRelationshipService where TResource : /// /// The identifier of the primary resource. /// The relationship to add resources to. - /// The resources to add to the relationship. - Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources); + /// The resources to add to the relationship. + Task AddToToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs index d49a9324e4..9cbb6d18df 100644 --- a/src/JsonApiDotNetCore/Services/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -13,7 +13,7 @@ public interface ICreateService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to create a new resource. + /// Handles a json:api request to create a new resource with attributes, relationships or both. /// Task CreateAsync(TResource resource); } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index 226a77fb80..2a6cdcdb14 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -16,7 +16,7 @@ public interface IRemoveFromRelationshipService where TResour /// /// The identifier of the primary resource. /// The relationship to remove resources from. - /// The resources to remove from the relationship. - Task RemoveFromRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources); + /// The resources to remove from the relationship. + Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 014c83fc23..e26de0645c 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -12,11 +12,11 @@ public interface ISetRelationshipService : ISetRelationshipService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to perform a complete replacement of the value of a relationship. + /// Handles a json:api request to perform a complete replacement of a relationship on an existing resource. /// /// The identifier of the primary resource. /// The relationship for which to perform a complete replacement. - /// The resources to perform the complete replacement with. - Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResources); + /// The resources to assign to the relationship. + Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index 7afd8e1b91..a1499e3613 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -13,7 +13,7 @@ public interface IUpdateService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to update an existing resource. + /// Handles a json:api request to update an existing resource with attributes, relationships or both. May contain a partial set of attributes. /// Task UpdateAsync(TId id, TResource resourceFromRequest); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6d986c55d9..6abeafe336 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -252,26 +252,26 @@ public virtual async Task CreateAsync(TResource resource) /// // triggered by POST /articles/{id}/relationships/{relationshipName} - public async Task AddRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources) + public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds) { - _traceWriter.LogMethodStart(new { id, secondaryResources }); + _traceWriter.LogMethodStart(new { id, secondaryResourceIds }); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); - if (secondaryResources.Any()) + if (secondaryResourceIds.Any()) { try { - await _repository.AddToRelationshipAsync(id, secondaryResources); + await _repository.AddToToManyRelationshipAsync(id, secondaryResourceIds); } catch (RepositorySaveException) { var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); - var assignment = new Dictionary { { _request.Relationship, secondaryResources } }; + var assignment = new Dictionary { { _request.Relationship, secondaryResourceIds } }; await AssertValuesOfRelationshipAssignmentExistAsync(assignment); throw; @@ -324,9 +324,9 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR /// // triggered by PATCH /articles/{id}/relationships/{relationshipName} - public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResources) + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -342,7 +342,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, try { - await _repository.SetRelationshipAsync(id, secondaryResources); + await _repository.SetRelationshipAsync(id, secondaryResourceIds); } catch (RepositorySaveException) { @@ -352,9 +352,9 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertPrimaryResourceExists(primaryResource); } - if (secondaryResources != null) + if (secondaryResourceIds != null) { - var assignment = new Dictionary { { _request.Relationship, secondaryResources } }; + var assignment = new Dictionary { { _request.Relationship, secondaryResourceIds } }; await AssertValuesOfRelationshipAssignmentExistAsync(assignment); } @@ -402,9 +402,9 @@ public virtual async Task DeleteAsync(TId id) /// // triggered by DELETE /articles/{id}/relationships/{relationshipName} - public async Task RemoveFromRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResources) + public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResources}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); @@ -412,7 +412,7 @@ public async Task RemoveFromRelationshipAsync(TId id, string relationshipName, I try { - await _repository.RemoveFromRelationshipAsync(id, secondaryResources); + await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); } catch (RepositorySaveException) { diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 5c3ad8ba19..7cbcdffd05 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -166,9 +166,9 @@ private class IntResourceService : IResourceService public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource resourceFromRequest) => throw new NotImplementedException(); - public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResources) => throw new NotImplementedException(); - public Task AddRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); - public Task RemoveFromRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -180,9 +180,9 @@ private class GuidResourceService : IResourceService public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource resourceFromRequest) => throw new NotImplementedException(); - public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResources) => throw new NotImplementedException(); - public Task AddRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); - public Task RemoveFromRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResources) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); } From 4d677a76b0b7fd0ff3e207b0438ccc5228109d93 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 12 Oct 2020 12:17:45 +0200 Subject: [PATCH 037/240] renamed exception --- .../Errors/RepositorySaveException.cs | 16 ---------------- .../DataStorePersistFailedException.cs | 13 +++++++++++++ .../EntityFrameworkCoreRepository.cs | 2 +- .../Services/JsonApiResourceService.cs | 12 ++++++------ 4 files changed, 20 insertions(+), 23 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Errors/RepositorySaveException.cs create mode 100644 src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs diff --git a/src/JsonApiDotNetCore/Errors/RepositorySaveException.cs b/src/JsonApiDotNetCore/Errors/RepositorySaveException.cs deleted file mode 100644 index f332ae45e4..0000000000 --- a/src/JsonApiDotNetCore/Errors/RepositorySaveException.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Net; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when the repository layer fails to save a new state. - /// - public sealed class RepositorySaveException : Exception - { - public RepositorySaveException(Exception exception) : base(exception.Message, exception) { } - } -} diff --git a/src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs b/src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs new file mode 100644 index 0000000000..db6d89ec68 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// The error that is thrown when the underlying data store is unable to persist changes. + /// + public sealed class DataStorePersistFailedException : Exception + { + public DataStorePersistFailedException(Exception exception) + : base("Failed to persist changes in the underlying data store.", exception) { } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3584b648a9..4190be16c3 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -478,7 +478,7 @@ private async Task TrySave() } catch (DbUpdateException exception) { - throw new RepositorySaveException(exception); + throw new DataStorePersistFailedException(exception); } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6abeafe336..f9c9f9f725 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -231,7 +231,7 @@ public virtual async Task CreateAsync(TResource resource) { await _repository.CreateAsync(resource); } - catch (RepositorySaveException) + catch (DataStorePersistFailedException) { var relationshipsWithValues = GetPopulatedRelationships(resource); await AssertValuesOfRelationshipAssignmentExistAsync(relationshipsWithValues); @@ -266,7 +266,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, { await _repository.AddToToManyRelationshipAsync(id, secondaryResourceIds); } - catch (RepositorySaveException) + catch (DataStorePersistFailedException) { var primaryResource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(primaryResource); @@ -300,7 +300,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR { await _repository.UpdateAsync(resourceFromRequest, localResource); } - catch (RepositorySaveException) + catch (DataStorePersistFailedException) { var assignments = GetPopulatedRelationships(resourceFromRequest); await AssertValuesOfRelationshipAssignmentExistAsync(assignments); @@ -344,7 +344,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, { await _repository.SetRelationshipAsync(id, secondaryResourceIds); } - catch (RepositorySaveException) + catch (DataStorePersistFailedException) { if (primaryResource == null) { @@ -386,7 +386,7 @@ public virtual async Task DeleteAsync(TId id) { await _repository.DeleteAsync(id); } - catch (RepositorySaveException) + catch (DataStorePersistFailedException) { succeeded = false; resource = await GetProjectedPrimaryResourceById(id); @@ -414,7 +414,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN { await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); } - catch (RepositorySaveException) + catch (DataStorePersistFailedException) { var resource = await GetProjectedPrimaryResourceById(id); AssertPrimaryResourceExists(resource); From 4d95ea3e596639507c1c850b6dda87bc8e3f5c6d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 12 Oct 2020 13:02:00 +0200 Subject: [PATCH 038/240] First steps in error refactoring --- .../Errors/MissingResourceInRelationship.cs | 18 +++++++++++ .../Errors/ResourceNotFoundException.cs | 29 ++--------------- ...RelationshipAssignmentNotFoundException.cs | 32 +++++++++++++++++++ .../Middleware/ExceptionHandler.cs | 6 ++++ .../Services/JsonApiResourceService.cs | 3 +- 5 files changed, 60 insertions(+), 28 deletions(-) create mode 100644 src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs create mode 100644 src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs diff --git a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs new file mode 100644 index 0000000000..421592abd8 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs @@ -0,0 +1,18 @@ +using System; + +namespace JsonApiDotNetCore.Errors +{ + public sealed class MissingResourceInRelationship + { + public string RelationshipName { get; } + public string ResourceType { get; } + public string ResourceId { get; } + + public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) + { + RelationshipName = relationshipName ?? throw new ArgumentNullException(nameof(relationshipName)); + ResourceType = resourceType ?? throw new ArgumentNullException(nameof(resourceType)); + ResourceId = resourceId ?? throw new ArgumentNullException(nameof(resourceId)); + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 1261e6401b..a9d127ee59 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using System.Net; using JsonApiDotNetCore.Serialization.Objects; @@ -10,36 +8,13 @@ namespace JsonApiDotNetCore.Errors /// public sealed class ResourceNotFoundException : JsonApiException { - public ResourceNotFoundException(string resourceId, string resourceType) : base( - new Error(HttpStatusCode.NotFound) + public ResourceNotFoundException(string resourceId, string resourceType) + : base(new Error(HttpStatusCode.NotFound) { Title = "The requested resource does not exist.", Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." - }) { } - - public ResourceNotFoundException(Dictionary> nonExistingResources) : base( - new Error(HttpStatusCode.NotFound) - { - Title = "The requested resources do not exist.", - Detail = CreateErrorMessageForMultipleMissing(nonExistingResources) }) { - var pairs = nonExistingResources.ToArray(); - if (pairs.Count() == 1 && pairs[0].Value.Count == 1) - { - var (resourceType, value) = pairs[0]; - var resourceId = value.First(); - - throw new ResourceNotFoundException(resourceId, resourceType); - } - } - - private static string CreateErrorMessageForMultipleMissing(Dictionary> missingResources) - { - var errorDetailLines = missingResources.Select(p => $"{p.Key}: {string.Join(',', p.Value)}") - .ToArray(); - - return $@"For the following types, the resources with the specified ids do not exist:\n{string.Join('\n', errorDetailLines)}"; } } } diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs new file mode 100644 index 0000000000..70555547b5 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when assigning one or more non-existing resources to a relationship. + /// + public sealed class ResourcesInRelationshipAssignmentNotFoundException : Exception + { + public IReadOnlyCollection Errors { get; } + + public ResourcesInRelationshipAssignmentNotFoundException(IEnumerable missingResources) + { + Errors = missingResources.Select(CreateError).ToList(); + } + + private Error CreateError(MissingResourceInRelationship missingResourceInRelationship) + { + return new Error(HttpStatusCode.NotFound) + { + Title = "Resource being assigned to relationship do not exist.", + Detail = + $"Resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + + $"being assigned to relationship '{missingResourceInRelationship.RelationshipName}' does not exist." + }; + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index c0f3e2f6e0..f102f5ac7b 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,6 +71,12 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) return new ErrorDocument(modelStateException.Errors); } + if (exception is ResourcesInRelationshipAssignmentNotFoundException + resourcesInRelationshipAssignmentNotFound) + { + return new ErrorDocument(resourcesInRelationshipAssignmentNotFound.Errors); + } + Error error = exception is JsonApiException jsonApiException ? jsonApiException.Error : new Error(HttpStatusCode.InternalServerError) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index f9c9f9f725..5e531aee9b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -535,7 +535,8 @@ private async Task AssertValuesOfRelationshipAssignmentExistAsync(Dictionary Date: Mon, 12 Oct 2020 13:10:19 +0200 Subject: [PATCH 039/240] removed comments --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 5e531aee9b..356a2cd21d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -101,7 +101,6 @@ public virtual async Task> GetAsync() } /// - // triggered by GET /articles/{id} public virtual async Task GetAsync(TId id) { _traceWriter.LogMethodStart(new {id}); @@ -145,7 +144,6 @@ private async Task GetPrimaryResourceById(TId id, bool allowTopSparse } /// - // triggered by GET /articles/{id}/{relationshipName} public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); @@ -187,7 +185,6 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN } /// - // triggered by GET /articles/{id}/relationships/{relationshipName} public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); @@ -216,7 +213,6 @@ public virtual async Task GetRelationshipAsync(TId id, string relatio } /// - // triggered by POST /articles public virtual async Task CreateAsync(TResource resource) { _traceWriter.LogMethodStart(new {resource}); @@ -251,7 +247,6 @@ public virtual async Task CreateAsync(TResource resource) } /// - // triggered by POST /articles/{id}/relationships/{relationshipName} public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds) { _traceWriter.LogMethodStart(new { id, secondaryResourceIds }); @@ -280,7 +275,6 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, } /// - // triggered by PATCH /articles/{id} public virtual async Task UpdateAsync(TId id, TResource resourceFromRequest) { _traceWriter.LogMethodStart(new {id, resourceFromRequest}); @@ -323,7 +317,6 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR } /// - // triggered by PATCH /articles/{id}/relationships/{relationshipName} public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); @@ -368,7 +361,6 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } /// - // triggered by DELETE /articles/{id public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); @@ -401,7 +393,6 @@ public virtual async Task DeleteAsync(TId id) } /// - // triggered by DELETE /articles/{id}/relationships/{relationshipName} public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); From 0a0612b5e1e245dafb04e301f67d2369fa7fc394 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 12 Oct 2020 19:03:40 +0200 Subject: [PATCH 040/240] chore: refactor service layer --- ...lationshipAssignmentsNotFoundException.cs} | 6 +- .../Middleware/ExceptionHandler.cs | 2 +- ...n.cs => DataStoreUpdateFailedException.cs} | 4 +- .../EntityFrameworkCoreRepository.cs | 185 +++++++-------- .../Repositories/IRepositoryAccessor.cs | 2 +- .../Repositories/IResourceWriteRepository.cs | 2 +- .../Resources/IResourceFactory.cs | 2 +- .../Resources/ResourceFactory.cs | 7 +- .../Services/JsonApiResourceService.cs | 216 +++++++++--------- 9 files changed, 213 insertions(+), 213 deletions(-) rename src/JsonApiDotNetCore/Errors/{ResourcesInRelationshipAssignmentNotFoundException.cs => ResourcesInRelationshipAssignmentsNotFoundException.cs} (75%) rename src/JsonApiDotNetCore/Repositories/{DataStorePersistFailedException.cs => DataStoreUpdateFailedException.cs} (68%) diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs similarity index 75% rename from src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs rename to src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs index 70555547b5..235f8de496 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs @@ -9,11 +9,11 @@ namespace JsonApiDotNetCore.Errors /// /// The error that is thrown when assigning one or more non-existing resources to a relationship. /// - public sealed class ResourcesInRelationshipAssignmentNotFoundException : Exception + public sealed class ResourcesInRelationshipAssignmentsNotFoundException : Exception { public IReadOnlyCollection Errors { get; } - public ResourcesInRelationshipAssignmentNotFoundException(IEnumerable missingResources) + public ResourcesInRelationshipAssignmentsNotFoundException(IEnumerable missingResources) { Errors = missingResources.Select(CreateError).ToList(); } @@ -22,7 +22,7 @@ private Error CreateError(MissingResourceInRelationship missingResourceInRelatio { return new Error(HttpStatusCode.NotFound) { - Title = "Resource being assigned to relationship do not exist.", + Title = "A resource being assigned to a relationship does not exist.", Detail = $"Resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + $"being assigned to relationship '{missingResourceInRelationship.RelationshipName}' does not exist." diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index f102f5ac7b..b23c683288 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,7 +71,7 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) return new ErrorDocument(modelStateException.Errors); } - if (exception is ResourcesInRelationshipAssignmentNotFoundException + if (exception is ResourcesInRelationshipAssignmentsNotFoundException resourcesInRelationshipAssignmentNotFound) { return new ErrorDocument(resourcesInRelationshipAssignmentNotFound.Errors); diff --git a/src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailedException.cs similarity index 68% rename from src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs rename to src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailedException.cs index db6d89ec68..ce487088c7 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStorePersistFailedException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailedException.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCore.Repositories /// /// The error that is thrown when the underlying data store is unable to persist changes. /// - public sealed class DataStorePersistFailedException : Exception + public sealed class DataStoreUpdateFailedException : Exception { - public DataStorePersistFailedException(Exception exception) + public DataStoreUpdateFailedException(Exception exception) : base("Failed to persist changes in the underlying data store.", exception) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 4190be16c3..73c2d65c65 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -42,8 +42,6 @@ public EntityFrameworkCoreRepository( public class EntityFrameworkCoreRepository : IResourceRepository where TResource : class, IIdentifiable { - // ReSharper disable once StaticMemberInGenericType - private static readonly ConcurrentDictionary _foreignKeyCache = new ConcurrentDictionary(); private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; @@ -156,9 +154,9 @@ public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection(id)); + var primaryResource = (TResource)GetTrackedOrAttach(CreateInstance(id)); - await ApplyRelationshipAssignment(localResource, relationship, secondaryResourceIds); + await ApplyRelationshipAssignment(primaryResource, relationship, secondaryResourceIds); await TrySave(); } @@ -168,37 +166,37 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); var relationship = _targetedFields.Relationships.Single(); - var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); + var primaryResource = (TResource)GetTrackedOrAttach(CreateInstance(id)); - await LoadCurrentRelationship(localResource, relationship); + await LoadRelationship(primaryResource, relationship); - await ApplyRelationshipAssignment(localResource, relationship, secondaryResourceIds); + await ApplyRelationshipAssignment(primaryResource, relationship, secondaryResourceIds); await TrySave(); } /// - public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource localResource) + public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase) { - _traceWriter.LogMethodStart(new {resourceFromRequest, localResource}); + _traceWriter.LogMethodStart(new {resourceFromRequest, resourceFromDatabase}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); - if (localResource == null) throw new ArgumentNullException(nameof(localResource)); + if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); foreach (var attribute in _targetedFields.Attributes) { - attribute.SetValue(localResource, attribute.GetValue(resourceFromRequest)); + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } foreach (var relationship in _targetedFields.Relationships) { // A database entity might not be tracked if it was retrieved through projection. - localResource = (TResource) GetTrackedOrAttach(localResource); + resourceFromDatabase = (TResource) GetTrackedOrAttach(resourceFromDatabase); // Ensures complete replacements of relationships. - await LoadCurrentRelationship(localResource, relationship); + await LoadRelationship(resourceFromDatabase, relationship); var relationshipAssignment = relationship.GetValue(resourceFromRequest); - await ApplyRelationshipAssignment(localResource, relationship, relationshipAssignment); + await ApplyRelationshipAssignment(resourceFromDatabase, relationship, relationshipAssignment); } await TrySave(); @@ -209,7 +207,7 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - var resource = GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); + var resource = GetTrackedOrAttach(CreateInstance(id)); _dbContext.Remove(resource); await TrySave(); @@ -221,20 +219,28 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); var relationship = _targetedFields.Relationships.Single(); - var localResource = (TResource)GetTrackedOrAttach(_resourceFactory.CreateInstance(id)); + var primaryResource = (TResource)GetTrackedOrAttach(CreateInstance(id)); - await LoadCurrentRelationship(localResource, relationship); + await LoadRelationship(primaryResource, relationship); - var currentRelationshipAssignment = ((IReadOnlyCollection) relationship.GetValue(localResource)); + var currentRelationshipAssignment = ((IReadOnlyCollection)relationship.GetValue(primaryResource)); var newRelationshipAssignment = currentRelationshipAssignment.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToArray(); if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count()) { - await ApplyRelationshipAssignment(localResource, relationship, newRelationshipAssignment); + await ApplyRelationshipAssignment(primaryResource, relationship, newRelationshipAssignment); await TrySave(); } } + private TResource CreateInstance(TId id) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + return resource; + } + /// public virtual void FlushFromCache(TResource resource) { @@ -244,68 +250,6 @@ public virtual void FlushFromCache(TResource resource) _dbContext.Entry(trackedResource).State = EntityState.Detached; } - /// - /// Loads the inverse relationships to prevent foreign key constraints from being violated - /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// - /// Consider the following example: - /// person.todoItems = [t1,t2] is updated to [t3, t4]. If t3, and/or t4 was - /// already related to a other person, and these persons are NOT loaded into the - /// DbContext, then the query may cause a foreign key constraint. Loading - /// these "inverse relationships" into the DB context ensures EF core to take - /// this into account. - /// - /// - private async Task LoadInverseRelationships(object resources, RelationshipAttribute relationship) - { - var inverseNavigation = relationship.InverseNavigation; - - if (inverseNavigation != null) - { - if (relationship is HasOneAttribute hasOneRelationship) - { - var entityEntry = _dbContext.Entry(resources); - - if (IsOneToOne(hasOneRelationship)) - { - await entityEntry.Reference(inverseNavigation).LoadAsync(); - } - else - { - await entityEntry.Collection(inverseNavigation).LoadAsync(); - } - } - else if (!(relationship is HasManyThroughAttribute)) - { - var loadTasks = ((IReadOnlyCollection)resources) - .Select(resource => _dbContext.Entry(resource).Reference(inverseNavigation).LoadAsync()); - await Task.WhenAll(loadTasks); - } - } - } - - private bool IsOneToOne(HasOneAttribute relationship) - { - var relationshipType = relationship.RightType; - var inverseNavigation = relationship.InverseNavigation; - bool inversePropertyIsEnumerable; - - var inverseRelationship = _resourceGraph.GetRelationships(relationshipType).FirstOrDefault(r => r.Property.Name == inverseNavigation); - if (inverseRelationship == null) - { - // inverseRelationship is null when there is no RelationshipAttribute on the inverse navigation property. - // In this case we reflect on the type to figure out what kind of relationship is pointing back. - var inverseProperty = relationshipType.GetProperty(inverseNavigation).PropertyType; - inversePropertyIsEnumerable = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); - } - else - { - inversePropertyIsEnumerable = !(inverseRelationship is HasOneAttribute); - } - - return !inversePropertyIsEnumerable; - } - private void DetachRelationships(TResource resource) { foreach (var relationship in _targetedFields.Relationships) @@ -344,7 +288,7 @@ private void DetachRelationships(TResource resource) /// after which the reassignment `p1.todoItems = [t3, t4]` will actually /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. /// - protected async Task LoadCurrentRelationship(TResource resource, RelationshipAttribute relationship) + protected async Task LoadRelationship(TResource resource, RelationshipAttribute relationship) { if (resource == null) throw new ArgumentNullException(nameof(resource)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); @@ -371,8 +315,70 @@ protected async Task LoadCurrentRelationship(TResource resource, RelationshipAtt await (navigationEntry?.LoadAsync() ?? Task.CompletedTask); } + + /// + /// Loads the inverse relationships to prevent foreign key constraints from being violated + /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + /// + /// Consider the following example: + /// person.todoItems = [t1,t2] is updated to [t3, t4]. If t3, and/or t4 was + /// already related to a other person, and these persons are NOT loaded into the + /// DbContext, then the query may cause a foreign key constraint. Loading + /// these "inverse relationships" into the DB context ensures EF core to take + /// this into account. + /// + /// + private async Task LoadInverseRelationships(object relationshipAssignment, RelationshipAttribute relationship) + { + var inverseNavigation = relationship.InverseNavigation; + + if (inverseNavigation != null) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + var entityEntry = _dbContext.Entry(relationshipAssignment); + + if (IsOneToOne(hasOneRelationship)) + { + await entityEntry.Reference(inverseNavigation).LoadAsync(); + } + else + { + await entityEntry.Collection(inverseNavigation).LoadAsync(); + } + } + else if (!(relationship is HasManyThroughAttribute)) + { + var loadTasks = ((IReadOnlyCollection)relationshipAssignment) + .Select(resource => _dbContext.Entry(resource).Reference(inverseNavigation).LoadAsync()); + await Task.WhenAll(loadTasks); + } + } + } - private async Task ApplyRelationshipAssignment(TResource localResource, RelationshipAttribute relationship, object relationshipAssignment) + private bool IsOneToOne(HasOneAttribute relationship) + { + var relationshipType = relationship.RightType; + var inverseNavigation = relationship.InverseNavigation; + bool inversePropertyIsEnumerable; + + var inverseRelationship = _resourceGraph.GetRelationships(relationshipType).FirstOrDefault(r => r.Property.Name == inverseNavigation); + if (inverseRelationship == null) + { + // inverseRelationship is null when there is no RelationshipAttribute on the inverse navigation property. + // In this case we reflect on the type to figure out what kind of relationship is pointing back. + var inverseProperty = relationshipType.GetProperty(inverseNavigation).PropertyType; + inversePropertyIsEnumerable = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); + } + else + { + inversePropertyIsEnumerable = !(inverseRelationship is HasOneAttribute); + } + + return !inversePropertyIsEnumerable; + } + + private async Task ApplyRelationshipAssignment(TResource primaryResource, RelationshipAttribute relationship, object relationshipAssignment) { // Ensures the new relationship assignment will not result entities being tracked more than once. object trackedRelationshipAssignment = null; @@ -397,12 +403,12 @@ private async Task ApplyRelationshipAssignment(TResource localResource, Relation var foreignKey = GetForeignKey(relationship); if (foreignKey != null) { - foreignKey.SetValue(localResource, secondaryResourceId); - _dbContext.Entry(localResource).State = EntityState.Modified; + foreignKey.SetValue(primaryResource, secondaryResourceId); + _dbContext.Entry(primaryResource).State = EntityState.Modified; } } - relationship.SetValue(localResource, trackedRelationshipAssignment, _resourceFactory); + relationship.SetValue(primaryResource, trackedRelationshipAssignment, _resourceFactory); } private object GetTrackedRelationshipAssignment(object relationshipAssignment, Type relationshipType) @@ -441,7 +447,7 @@ private PropertyInfo GetForeignKey(RelationshipAttribute relationship) { PropertyInfo foreignKey = null; - if (relationship is HasOneAttribute && !_foreignKeyCache.TryGetValue(relationship, out foreignKey)) + if (relationship is HasOneAttribute) { var entityMetadata = _dbContext.Model.FindEntityType(typeof(TResource)); var foreignKeyMetadata = entityMetadata.FindNavigation(relationship.Property.Name).ForeignKey; @@ -451,8 +457,7 @@ private PropertyInfo GetForeignKey(RelationshipAttribute relationship) { foreignKey = null; } - - _foreignKeyCache.TryAdd(relationship, foreignKey); + } return foreignKey; @@ -478,7 +483,7 @@ private async Task TrySave() } catch (DbUpdateException exception) { - throw new DataStorePersistFailedException(exception); + throw new DataStoreUpdateFailedException(exception); } } } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs index 3aed6e1b95..7816c981e1 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Repositories public interface IRepositoryAccessor { /// - /// Gets resources by id. + /// Gets resources by filtering on id. /// /// The type for which to create a repository. /// The ids to filter on. diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 530d1af357..05859686b9 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -32,7 +32,7 @@ public interface IResourceWriteRepository /// /// Updates the attributes and relationships of an existing resource in the underlying data store. /// - Task UpdateAsync(TResource resourceFromRequest, TResource localResource); + Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase); /// /// Performs a complete replacement of the relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 61cd38f30e..fcd3dd4007 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -17,7 +17,7 @@ public interface IResourceFactory /// Creates a new resource object instance. /// /// The id that will be set for the instance, if provided. - public TResource CreateInstance(object id = null) where TResource : IIdentifiable; + public TResource CreateInstance() where TResource : IIdentifiable; /// /// Returns an expression tree that represents creating a new resource object instance. diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 009bdf5e21..a99b0967a6 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -31,14 +31,9 @@ public object CreateInstance(Type resourceType) } /// - public TResource CreateInstance(object id = null) where TResource : IIdentifiable + public TResource CreateInstance() where TResource : IIdentifiable { var identifiable = (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); - - if (id != null) - { - TypeHelper.SetResourceTypedId(identifiable, id); - } return identifiable; } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 356a2cd21d..a2e8d29451 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -107,7 +107,7 @@ public virtual async Task GetAsync(TId id) _hookExecutor?.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); - var primaryResource = await GetPrimaryResourceById(id, true); + var primaryResource = await GetPrimaryResourceById(id, Projection.TopSparseFieldSet); if (_hookExecutor != null) { @@ -118,31 +118,7 @@ public virtual async Task GetAsync(TId id) return primaryResource; } - private async Task GetPrimaryResourceById(TId id, bool allowTopSparseFieldSet) - { - var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); - if (!allowTopSparseFieldSet && primaryLayer.Projection != null) - { - // Discard any ?fields= or attribute exclusions from ResourceDefinition, because we need the full record. - - while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) - { - primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); - } - } - - var primaryResources = await _repository.GetAsync(primaryLayer); - - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); - - return primaryResource; - } - /// public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { @@ -227,15 +203,15 @@ public virtual async Task CreateAsync(TResource resource) { await _repository.CreateAsync(resource); } - catch (DataStorePersistFailedException) + catch (DataStoreUpdateFailedException) { - var relationshipsWithValues = GetPopulatedRelationships(resource); - await AssertValuesOfRelationshipAssignmentExistAsync(relationshipsWithValues); + var assignments = GetPopulatedRelationshipAssignments(resource); + await AssertResourcesInRelationshipAssignmentsExistAsync(assignments); throw; } - resource = await GetPrimaryResourceById(resource.Id, true); + resource = await GetPrimaryResourceById(resource.Id, Projection.TopSparseFieldSet); if (_hookExecutor != null) { @@ -261,13 +237,13 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, { await _repository.AddToToManyRelationshipAsync(id, secondaryResourceIds); } - catch (DataStorePersistFailedException) + catch (DataStoreUpdateFailedException) { - var primaryResource = await GetProjectedPrimaryResourceById(id); + var primaryResource = await GetPrimaryResourceById(id, Projection.PrimaryId); AssertPrimaryResourceExists(primaryResource); - - var assignment = new Dictionary { { _request.Relationship, secondaryResourceIds } }; - await AssertValuesOfRelationshipAssignmentExistAsync(assignment); + + var relationshipAssignment = (_request.Relationship, secondaryResourceIds); + await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignment); throw; } @@ -280,9 +256,9 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR _traceWriter.LogMethodStart(new {id, resourceFromRequest}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); - TResource localResource = await GetPrimaryResourceById(id, false); + TResource resourceFromDatabase = await GetPrimaryResourceById(id, Projection.None); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(localResource); + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); if (_hookExecutor != null) @@ -292,24 +268,24 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR try { - await _repository.UpdateAsync(resourceFromRequest, localResource); + await _repository.UpdateAsync(resourceFromRequest, resourceFromDatabase); } - catch (DataStorePersistFailedException) + catch (DataStoreUpdateFailedException) { - var assignments = GetPopulatedRelationships(resourceFromRequest); - await AssertValuesOfRelationshipAssignmentExistAsync(assignments); + var relationshipAssignments = GetPopulatedRelationshipAssignments(resourceFromRequest); + await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignments); throw; } if (_hookExecutor != null) { - _hookExecutor.AfterUpdate(AsList(localResource), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(localResource), ResourcePipeline.Patch); + _hookExecutor.AfterUpdate(AsList(resourceFromDatabase), ResourcePipeline.Patch); + _hookExecutor.OnReturn(AsList(resourceFromDatabase), ResourcePipeline.Patch); } - _repository.FlushFromCache(localResource); - TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, false); + _repository.FlushFromCache(resourceFromDatabase); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, Projection.None); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); @@ -328,7 +304,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, if (_hookExecutor != null) { - primaryResource = await GetProjectedPrimaryResourceById(id); + primaryResource = await GetPrimaryResourceById(id, Projection.PrimaryId); AssertPrimaryResourceExists(primaryResource); _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); } @@ -337,18 +313,18 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, { await _repository.SetRelationshipAsync(id, secondaryResourceIds); } - catch (DataStorePersistFailedException) + catch (DataStoreUpdateFailedException) { if (primaryResource == null) { - primaryResource = await GetProjectedPrimaryResourceById(id); + primaryResource = await GetPrimaryResourceById(id, Projection.PrimaryId); AssertPrimaryResourceExists(primaryResource); } if (secondaryResourceIds != null) { - var assignment = new Dictionary { { _request.Relationship, secondaryResourceIds } }; - await AssertValuesOfRelationshipAssignmentExistAsync(assignment); + var relationshipAssignment = (_request.Relationship, AsReadOnlyCollection(secondaryResourceIds)); + await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignment); } throw; @@ -368,7 +344,8 @@ public virtual async Task DeleteAsync(TId id) TResource resource = null; if (_hookExecutor != null) { - resource = _resourceFactory.CreateInstance(id); + resource = _resourceFactory.CreateInstance(); + resource.Id = id; _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); } @@ -378,10 +355,10 @@ public virtual async Task DeleteAsync(TId id) { await _repository.DeleteAsync(id); } - catch (DataStorePersistFailedException) + catch (DataStoreUpdateFailedException) { succeeded = false; - resource = await GetProjectedPrimaryResourceById(id); + resource = await GetPrimaryResourceById(id, Projection.PrimaryId); AssertPrimaryResourceExists(resource); throw; @@ -405,15 +382,48 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN { await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); } - catch (DataStorePersistFailedException) + catch (DataStoreUpdateFailedException) { - var resource = await GetProjectedPrimaryResourceById(id); + var resource = await GetPrimaryResourceById(id, Projection.PrimaryId); AssertPrimaryResourceExists(resource); throw; } } + private async Task GetPrimaryResourceById(TId id, Projection projectionToAdd) + { + var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); + + if (projectionToAdd == Projection.None && primaryLayer.Projection != null) + { + // Discard any ?fields= or attribute exclusions from ResourceDefinition, because we need the full record. + while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) + { + primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); + } + } + else if (projectionToAdd == Projection.PrimaryId) + { + // https://github.com/dotnet/efcore/issues/20502 + if (!TypeHelper.ConstructorDependsOnDbContext(_request.PrimaryResource.ResourceType)) + { + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + primaryLayer.Projection = new Dictionary { { idAttribute, null } }; + } + } + + var primaryResources = await _repository.GetAsync(primaryLayer); + + var primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + return primaryResource; + } + private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) { var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); @@ -423,45 +433,26 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt return existingFilter == null ? filterById - : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); + : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); } - private async Task GetProjectedPrimaryResourceById(TId id) - { - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - - queryLayer.Filter = IncludeFilterById(id, queryLayer.Filter); - - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - - if (!TypeHelper.ConstructorDependsOnDbContext(_request.PrimaryResource.ResourceType)) - { - // https://github.com/dotnet/efcore/issues/20502 - queryLayer.Projection = new Dictionary { { idAttribute, null } }; - } - - var primaryResource = (await _repository.GetAsync(queryLayer)).SingleOrDefault(); - - return primaryResource; - } - - private Dictionary GetPopulatedRelationships(TResource requestResource) + private (RelationshipAttribute Relationship, IReadOnlyCollection Assignment)[] GetPopulatedRelationshipAssignments(TResource requestResource) { var assignments = _targetedFields.Relationships - .Select(relationship => (Relationship: relationship, Value: relationship.GetValue(requestResource))) - .Where(RelationshipIsPopulated) - .ToDictionary(r => r.Relationship, r => r.Value); + .Select(relationship => (Relationship: relationship, Assignment: AsReadOnlyCollection(relationship.GetValue(requestResource)))) + .Where(p => RelationshipIsPopulated(p.Assignment)) + .ToArray(); return assignments; } - private bool RelationshipIsPopulated((RelationshipAttribute Relationship, object Value) p) + private bool RelationshipIsPopulated(object assignment) { - if (p.Value is IIdentifiable hasOneValue) + if (assignment is IIdentifiable hasOneValue) { return true; } - else if (p.Value is IReadOnlyCollection hasManyValues) + else if (assignment is IReadOnlyCollection hasManyValues) { return hasManyValues.Any(); } @@ -470,7 +461,7 @@ private bool RelationshipIsPopulated((RelationshipAttribute Relationship, object return false; } } - + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -497,37 +488,29 @@ private void AssertRelationshipIsToMany() } } - private async Task AssertValuesOfRelationshipAssignmentExistAsync(Dictionary nonNullRelationshipAssignments) + private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (RelationshipAttribute, IReadOnlyCollection)[] assignments) { - var missingResources = new Dictionary>(); + var missingResourceErrors = new List(); - foreach (var assignment in nonNullRelationshipAssignments) + foreach (var (relationship, resources) in assignments) { - IReadOnlyCollection identifiers; - if (assignment.Value is IIdentifiable identifiable) - { - identifiers = new [] { identifiable.GetTypedId().ToString() }; - } - else - { - identifiers = ((IEnumerable)assignment.Value) - .Select(i => i.GetTypedId().ToString()) - .ToArray(); - } - - var resources = await _repositoryAccessor.GetResourcesByIdAsync(assignment.Key.RightType, identifiers); - var missing = identifiers.Where(id => resources.All(r => r.GetTypedId().ToString() != id)).ToArray(); - - if (missing.Any()) - { - missingResources.Add(_provider.GetResourceContext(assignment.Key.RightType).PublicName, missing.ToArray()); - } + var identifiers = (IReadOnlyCollection)resources.Select(i => i.GetTypedId().ToString()); + var databaseResources = await _repositoryAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); + + var errorsInAssignment = resources + .Where(sr => databaseResources.All(dbr => dbr.StringId != sr.StringId)) + .Select(sr => + new MissingResourceInRelationship( + _provider.GetResourceContext(relationship.RightType).PublicName, + _provider.GetResourceContext(sr.GetType()).PublicName, + sr.StringId)); + + missingResourceErrors.AddRange(errorsInAssignment); } - if (missingResources.Any()) - { - throw null; // TODO: Fix broken tests. - //throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); + if (missingResourceErrors.Any()) + { + throw new ResourcesInRelationshipAssignmentsNotFoundException(missingResourceErrors); } } @@ -535,6 +518,23 @@ private List AsList(TResource resource) { return new List { resource }; } + + private IReadOnlyCollection AsReadOnlyCollection(object relationshipAssignment) + { + if (relationshipAssignment is IIdentifiable hasOneAssignment) + { + return new[] { hasOneAssignment }; + } + + return (IReadOnlyCollection)relationshipAssignment; + } + + private enum Projection + { + None, + PrimaryId, + TopSparseFieldSet + } } /// From a8222ef15ca79751e83483c61e059f9d33278699 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 12 Oct 2020 19:20:05 +0200 Subject: [PATCH 041/240] chore: cleanup --- .../Controllers/TodoItemsCustomController.cs | 4 ++-- .../Services/JsonApiResourceService.cs | 10 +++++----- src/JsonApiDotNetCore/TypeHelper.cs | 12 ------------ 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index b67f91eb04..c944145c05 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -133,9 +133,9 @@ public async Task PatchAsync(TId id, [FromBody] T resource) } [HttpPatch("{id}/relationships/{relationshipName}")] - public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResources) + public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds) { - await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResources); + await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResourceIds); return Ok(); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index a2e8d29451..32bcad1e4e 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -329,7 +329,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, throw; } - + if (_hookExecutor != null && primaryResource != null) { _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); @@ -350,7 +350,7 @@ public virtual async Task DeleteAsync(TId id) } var succeeded = true; - + try { await _repository.DeleteAsync(id); @@ -377,7 +377,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); - + try { await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); @@ -518,7 +518,7 @@ private List AsList(TResource resource) { return new List { resource }; } - + private IReadOnlyCollection AsReadOnlyCollection(object relationshipAssignment) { if (relationshipAssignment is IIdentifiable hasOneAssignment) @@ -528,7 +528,7 @@ private IReadOnlyCollection AsReadOnlyCollection(object relations return (IReadOnlyCollection)relationshipAssignment; } - + private enum Projection { None, diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 37e4dbb535..bf72b13ce7 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -292,18 +292,6 @@ public static object ConvertStringIdToTypedId(Type resourceType, string stringId return tempResource.GetTypedId(); } - - /// - /// Sets the typed value of the id of an identifiable. - /// - public static void SetResourceTypedId(IIdentifiable identifiable, object id) - { - if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); - if (id == null) throw new ArgumentNullException(nameof(id)); - - identifiable.GetType().GetProperty(nameof(Identifiable.Id)).SetValue(identifiable, id); - } - /// /// Extension to use the LINQ cast method in a non-generic way: /// From ad76ccbc116d0a769b05a7c493df6dd018790b65 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 12 Oct 2020 22:10:05 +0200 Subject: [PATCH 042/240] fix: test error missing relationship assignment resources --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 +- .../Acceptance/Spec/UpdatingRelationshipsTests.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 32bcad1e4e..2b972673f0 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -494,7 +494,7 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (Re foreach (var (relationship, resources) in assignments) { - var identifiers = (IReadOnlyCollection)resources.Select(i => i.GetTypedId().ToString()); + IReadOnlyCollection identifiers = (IReadOnlyCollection)resources.Select(i => i.GetTypedId().ToString()).ToArray(); var databaseResources = await _repositoryAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); var errorsInAssignment = resources diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 1ce6b4a401..42c8305e24 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -277,9 +277,14 @@ public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_R // Act var response = await client.SendAsync(request); - var responseBody = await response.Content.ReadAsStringAsync(); + var body = await response.Content.ReadAsStringAsync(); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); - Assert.Contains("For the following types, the resources with the specified ids do not exist:\\\\npeople: 900000,900001\\ntodoItems: 900002\"", responseBody); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Equal(3, errorDocument.Errors.Count); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'people' with ID '900000' being assigned to relationship 'people' does not exist.",errorDocument.Errors[0].Detail); } [Fact] From 1e8ebddff1d6423c9eddacbae4d4994f1d27233d Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 12 Oct 2020 22:22:15 +0200 Subject: [PATCH 043/240] feat: support for composite foreign keys --- .../Controllers/ProductsController.cs | 18 ++++++ .../Data/AppDbContext.cs | 12 ++++ .../Models/Category.cs | 32 ++++++++++ .../Models/Product.cs | 18 ++++++ .../EntityFrameworkCoreRepository.cs | 32 +++++----- .../Acceptance/Spec/UpdatingDataTests.cs | 60 ++++++++++++++++++- 6 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Category.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Product.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs new file mode 100644 index 0000000000..d17bb7b00a --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class ProductsController : JsonApiController + { + public ProductsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 20ba2d0f88..a8a38daed7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -22,6 +22,10 @@ public sealed class AppDbContext : DbContext public DbSet ArticleTags { get; set; } public DbSet Tags { get; set; } public DbSet Blogs { get; set; } + + public DbSet Products { get; set; } + + public DbSet Categories { get; set; } public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) { @@ -92,6 +96,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasOne(p => p.OneToOneTodoItem) .WithOne(p => p.OneToOnePerson) .HasForeignKey(p => p.OneToOnePersonId); + + modelBuilder.Entity() + .HasKey(c => new { c.CountryId, c.ShopId }); + + modelBuilder.Entity() + .HasOne(p => p.Category) + .WithMany(c => c.Products) + .HasForeignKey(p => new { p.CountryId, p.ShopId }); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs new file mode 100644 index 0000000000..08695e15e2 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Category : Identifiable + { + public override string Id + { + get => $"{CountryId}-{ShopId}"; + set + { + var split = value.Split('-'); + CountryId = int.Parse(split[0]); + ShopId = split[1]; + } + + } + + [Attr] + public string Name { get; set; } + + [HasMany] + public ICollection Products { get; set; } + + public int? CountryId { get; set; } + + public string ShopId { get; set; } + + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Product.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Product.cs new file mode 100644 index 0000000000..91c502ee97 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Product.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Product : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasOne] + public Category Category { get; set; } + + public int? CountryId { get; set; } + + public string ShopId { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 73c2d65c65..32b16ada85 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -15,6 +15,7 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -306,9 +307,10 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute } else if (relationship is HasOneAttribute hasOneRelationship) { - if (GetForeignKey(hasOneRelationship) == null) - { // If the primary resource is the dependent side of a to-one relationship, there can be no - // FK violations resulting from a the implicit removal. + var foreignKeyProperties = GetForeignKeys(hasOneRelationship); + if (foreignKeyProperties.Count() != 1) + { // If the primary resource is the dependent side of a to-one relationship, there can be no FK + // violations resulting from a the implicit removal. navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); } } @@ -400,14 +402,14 @@ private async Task ApplyRelationshipAssignment(TResource primaryResource, Relati secondaryResourceId = secondaryResource.GetTypedId(); } - var foreignKey = GetForeignKey(relationship); - if (foreignKey != null) + var foreignKeyProperties = GetForeignKeys(relationship); + if (foreignKeyProperties.Count() == 1) { - foreignKey.SetValue(primaryResource, secondaryResourceId); + foreignKeyProperties.First().SetValue(primaryResource, secondaryResourceId); _dbContext.Entry(primaryResource).State = EntityState.Modified; } } - + relationship.SetValue(primaryResource, trackedRelationshipAssignment, _resourceFactory); } @@ -443,24 +445,22 @@ private object GetTrackedRelationshipAssignment(object relationshipAssignment, T return trackedRelationshipAssignment; } - private PropertyInfo GetForeignKey(RelationshipAttribute relationship) + private PropertyInfo[] GetForeignKeys(RelationshipAttribute relationship) { - PropertyInfo foreignKey = null; - if (relationship is HasOneAttribute) { var entityMetadata = _dbContext.Model.FindEntityType(typeof(TResource)); var foreignKeyMetadata = entityMetadata.FindNavigation(relationship.Property.Name).ForeignKey; - foreignKey = foreignKeyMetadata.Properties[0].PropertyInfo; + + var declaringEntiyType = foreignKeyMetadata.DeclaringEntityType.ClrType; - if (foreignKey?.DeclaringType != typeof(TResource)) + if (declaringEntiyType == typeof(TResource)) { - foreignKey = null; + return foreignKeyMetadata.Properties.Select(p => p.PropertyInfo).Where(pi => pi != null).ToArray(); } - } - - return foreignKey; + + return new PropertyInfo[0]; } private IIdentifiable GetTrackedOrAttach(IIdentifiable resource) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 163e82d4ea..ab7c29d7cf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -107,7 +107,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Response422IfUpdatingNotSettableAttribute() + public async Task Response_422_If_Updating_Not_Settable_Attribute() { // Arrange var loggerFactory = _testContext.Factory.Services.GetRequiredService(); @@ -481,5 +481,63 @@ await _testContext.RunOnDatabaseAsync(async dbContext => updated.Owner.Id.Should().Be(person.Id); }); } + + [Fact] + public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Keys() + { + // Arrange + + var product = new Product + { + Name = "Croissants" + }; + var category = new Category + { + Id = "4234-FRENCHSPECIALTIES", + Name = "French Specialties" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + dbContext.AddRange(product, category); + await dbContext.SaveChangesAsync(); + product.Category = category; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "products", + id = product.Id, + relationships = new Dictionary + { + ["category"] = new + { + data = (object)null + } + } + } + }; + + var route = "/api/v1/products/" + product.Id; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var assertProduct = await dbContext.Products + .Include(m => m.Category) + .SingleAsync(h => h.Id == product.Id); + + assertProduct.Category.Should().BeNull(); + }); + } } } From e0be5179947de012038604bccef46ec4459b98db Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 12 Oct 2020 23:32:58 +0200 Subject: [PATCH 044/240] feat: return secondary resources from GetRelationshipAsync service pipeline --- .../Controllers/BaseJsonApiController.cs | 4 ++-- .../Serialization/Building/ILinkBuilder.cs | 2 ++ .../Serialization/Building/LinkBuilder.cs | 10 ++++++++++ .../Serialization/FieldsToSerialize.cs | 16 +++++++++++++++- .../Serialization/IResponseSerializer.cs | 13 ------------- .../Serialization/ResponseSerializer.cs | 15 +++++---------- .../Serialization/ResponseSerializerFactory.cs | 7 ++----- .../Services/IGetRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 5 +++-- .../Spec/FetchingRelationshipsTests.cs | 5 ++--- 10 files changed, 42 insertions(+), 37 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index f85e9ba1df..a6c08171f7 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -143,9 +143,9 @@ public virtual async Task GetRelationshipAsync(TId id, string rel if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + var relationshipAssignment = await _getRelationship.GetRelationshipAsync(id, relationshipName); - return Ok(relationship); + return Ok(relationshipAssignment); } /// diff --git a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs index ee54290c92..39137d2612 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs @@ -13,10 +13,12 @@ public interface ILinkBuilder /// Builds the links object that is included in the top-level of the document. /// TopLevelLinks GetTopLevelLinks(); + /// /// Builds the links object for resources in the primary data. /// ResourceLinks GetResourceLinks(string resourceName, string id); + /// /// Builds the links object that is included in the values of the . /// diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index 4e0cb1011f..9773b7630b 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -109,6 +109,11 @@ private string GetSelfTopLevelLink(ResourceContext resourceContext, Action private bool ShouldAddResourceLink(ResourceContext resourceContext, LinkTypes link) { + if (_request.Kind == EndpointKind.Relationship) + { + return false; + } + if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) { return resourceContext.ResourceLinks.HasFlag(link); diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index f08cbab1e6..0a79c70b82 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -15,15 +16,18 @@ public class FieldsToSerialize : IFieldsToSerialize private readonly IResourceGraph _resourceGraph; private readonly IEnumerable _constraintProviders; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IJsonApiRequest _jsonApiRequest; public FieldsToSerialize( IResourceGraph resourceGraph, IEnumerable constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiRequest jsonApiRequest) { _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(jsonApiRequest)); } /// @@ -31,6 +35,11 @@ public IReadOnlyCollection GetAttributes(Type resourceType, Relat { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (_jsonApiRequest.Kind == EndpointKind.Relationship) + { + return new AttrAttribute[0]; + } + var sparseFieldSetAttributes = _constraintProviders .SelectMany(p => p.GetConstraints()) .Where(expressionInScope => relationship == null @@ -79,6 +88,11 @@ public IReadOnlyCollection GetRelationships(Type type) { if (type == null) throw new ArgumentNullException(nameof(type)); + if (_jsonApiRequest.Kind == EndpointKind.Relationship) + { + return new RelationshipAttribute[0]; + } + return _resourceGraph.GetRelationships(type); } } diff --git a/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs deleted file mode 100644 index 31635d5e36..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - internal interface IResponseSerializer - { - /// - /// Sets the designated request relationship in the case of requests of - /// the form a /articles/1/relationships/author. - /// - RelationshipAttribute RequestRelationship { get; set; } - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 2e5b0a4afc..c608b54093 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -22,11 +22,8 @@ namespace JsonApiDotNetCore.Serialization /// /// Type of the resource associated with the scope of the request /// for which this serializer is used. - public class ResponseSerializer : BaseSerializer, IJsonApiSerializer, IResponseSerializer - where TResource : class, IIdentifiable + public class ResponseSerializer : BaseSerializer, IJsonApiSerializer where TResource : class, IIdentifiable { - public RelationshipAttribute RequestRelationship { get; set; } - private readonly IFieldsToSerialize _fieldsToSerialize; private readonly IJsonApiOptions _options; private readonly IMetaBuilder _metaBuilder; @@ -84,17 +81,13 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) /// internal string SerializeSingle(IIdentifiable resource) { - if (RequestRelationship != null && resource != null) - { - var relationship = ((ResponseResourceObjectBuilder)ResourceObjectBuilder).Build(resource, RequestRelationship); - return SerializeObject(relationship, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); - } - var (attributes, relationships) = GetFieldsToSerialize(); var document = Build(resource, attributes, relationships); var resourceObject = document.SingleData; if (resourceObject != null) + { resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + } AddTopLevelObjects(document); @@ -120,7 +113,9 @@ internal string SerializeMany(IReadOnlyCollection resources) { var links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); if (links == null) + { break; + } resourceObject.Links = links; } diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs index 0232eec083..6f995d061d 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -21,17 +21,14 @@ public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceP } /// - /// Initializes the server serializer using the - /// associated with the current request. + /// Initializes the server serializer using the associated with the current request. /// public IJsonApiSerializer GetSerializer() { var targetType = GetDocumentType(); var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); - var serializer = (IResponseSerializer)_provider.GetRequiredService(serializerType); - if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null) - serializer.RequestRelationship = _request.Relationship; + var serializer = _provider.GetRequiredService(serializerType); return (IJsonApiSerializer)serializer; } diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index 7cd926b3a0..444bba4ad5 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -15,6 +15,6 @@ public interface IGetRelationshipService /// /// Handles a json:api request to retrieve a single relationship. /// - Task GetRelationshipAsync(TId id, string relationshipName); + Task GetRelationshipAsync(TId id, string relationshipName); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2b972673f0..29553342e8 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -161,7 +161,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN } /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); AssertRelationshipExists(relationshipName); @@ -185,7 +185,8 @@ public virtual async Task GetRelationshipAsync(TId id, string relatio primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); } - return primaryResource; + var relationshipAssignment = _request.Relationship.GetValue(primaryResource); + return relationshipAssignment; } /// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 3c65dec700..bf64c92d36 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -62,8 +62,7 @@ public async Task When_getting_existing_ToOne_relationship_it_should_succeed() string expected = @"{ ""links"": { - ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"", - ""related"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" + ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"" }, ""data"": { ""type"": ""people"", @@ -116,7 +115,7 @@ public async Task When_getting_existing_ToMany_relationship_it_should_succeed() var expected = @"{ ""links"": { ""self"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"", - ""related"": ""http://localhost/api/v1/authors/" + author.StringId + @"/articles"" + ""first"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"" }, ""data"": [ { From 37da5630a8ebbf3ab033efe56a23ebbc1ff80f6f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 10:43:42 +0200 Subject: [PATCH 045/240] fix broken build --- .../Serialization/JsonApiSerializerBenchmarks.cs | 2 +- .../Services/WorkItemService.cs | 2 +- .../Queries/Expressions/EqualsAnyOfExpression.cs | 6 ++++++ .../Repositories/EntityFrameworkCoreRepository.cs | 3 --- .../Services/JsonApiResourceService.cs | 5 +++-- .../Extensions/IServiceCollectionExtensionsTests.cs | 4 ++-- .../Serialization/Server/ResponseSerializerTests.cs | 11 ----------- 7 files changed, 13 insertions(+), 20 deletions(-) diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index da0fa71ca1..f9f294c26b 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -49,7 +49,7 @@ private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resource var accessor = new Mock().Object; - return new FieldsToSerialize(resourceGraph, constraintProviders, accessor); + return new FieldsToSerialize(resourceGraph, constraintProviders, accessor, request); } [Benchmark] diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 07280eadb4..b596e62a16 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -41,7 +41,7 @@ public Task GetSecondaryAsync(int id, string relationshipName) throw new NotImplementedException(); } - public Task GetRelationshipAsync(int id, string relationshipName) + public Task GetRelationshipAsync(int id, string relationshipName) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs index f219241af6..07e78c1595 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -19,6 +19,12 @@ public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, { TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); Constants = constants ?? throw new ArgumentNullException(nameof(constants)); + + if (constants.Count < 2) + { + // TODO: Update tests. + throw new ArgumentException("At least two constants are required.", nameof(constants)); + } } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 32b16ada85..c0b53e89fc 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -1,12 +1,10 @@ using System; using System.Collections; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -15,7 +13,6 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 29553342e8..2f5b95bd96 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -164,6 +164,8 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + AssertRelationshipExists(relationshipName); _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); @@ -185,8 +187,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); } - var relationshipAssignment = _request.Relationship.GetValue(primaryResource); - return relationshipAssignment; + return _request.Relationship.GetValue(primaryResource); } /// diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 7cbcdffd05..3b4a904667 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -164,7 +164,7 @@ private class IntResourceService : IResourceService public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource resourceFromRequest) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); public Task AddToToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); @@ -178,7 +178,7 @@ private class GuidResourceService : IResourceService public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); + public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource resourceFromRequest) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 46eeb5fb27..732b74d71c 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -355,8 +355,6 @@ public void SerializeSingleWithRequestRelationship_NullToOneRelationship_CanSeri // Arrange var resource = new OneToOnePrincipal { Id = 2, Dependent = null }; var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToOnePrincipal t) => t.Dependent).First(); - serializer.RequestRelationship = requestRelationship; // Act string serialized = serializer.SerializeSingle(resource); @@ -373,9 +371,6 @@ public void SerializeSingleWithRequestRelationship_PopulatedToOneRelationship_Ca // Arrange var resource = new OneToOnePrincipal { Id = 2, Dependent = new OneToOneDependent { Id = 1 } }; var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToOnePrincipal t) => t.Dependent).First(); - serializer.RequestRelationship = requestRelationship; - // Act string serialized = serializer.SerializeSingle(resource); @@ -399,9 +394,6 @@ public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSe // Arrange var resource = new OneToManyPrincipal { Id = 2, Dependents = new HashSet() }; var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToManyPrincipal t) => t.Dependents).First(); - serializer.RequestRelationship = requestRelationship; - // Act string serialized = serializer.SerializeSingle(resource); @@ -418,9 +410,6 @@ public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_C // Arrange var resource = new OneToManyPrincipal { Id = 2, Dependents = new HashSet { new OneToManyDependent { Id = 1 } } }; var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToManyPrincipal t) => t.Dependents).First(); - serializer.RequestRelationship = requestRelationship; - // Act string serialized = serializer.SerializeSingle(resource); From ab5a05d694c2a20901d3cb160584097fb776936c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 12:53:14 +0200 Subject: [PATCH 046/240] fix broken tests --- .../Services/CustomArticleService.cs | 2 +- .../JsonApiApplicationBuilder.cs | 2 +- .../Internal/Execution/HookExecutorHelper.cs | 27 ++++++++++-- ...ssor.cs => IResourceRepositoryAccessor.cs} | 2 +- ...essor.cs => ResourceRepositoryAccessor.cs} | 44 ++++++++++++------- .../Services/JsonApiResourceService.cs | 16 +++---- .../ServiceDiscoveryFacadeTests.cs | 4 +- .../Spec/UpdatingRelationshipsTests.cs | 2 +- .../Services/DefaultResourceService_Tests.cs | 2 +- 9 files changed, 67 insertions(+), 34 deletions(-) rename src/JsonApiDotNetCore/Repositories/{IRepositoryAccessor.cs => IResourceRepositoryAccessor.cs} (94%) rename src/JsonApiDotNetCore/Repositories/{RepositoryAccessor.cs => ResourceRepositoryAccessor.cs} (71%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 572eb7c972..0e1866e904 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -22,7 +22,7 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - IRepositoryAccessor repositoryAccessor, + IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 1e3b04ba00..ff5d1c27c0 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -187,7 +187,7 @@ private void AddRepositoryLayer() RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<>), typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(); + _services.AddScoped(); } private void AddServiceLayer() diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs index d54dedde9b..f3c00d5c47 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs @@ -130,15 +130,19 @@ private IHooksDiscovery GetHookDiscovery(Type resourceType) return discovery; } - private IEnumerable GetWhereAndInclude(IEnumerable ids, RelationshipAttribute[] relationshipsToNextLayer) where TResource : class, IIdentifiable + private IEnumerable GetWhereAndInclude(IReadOnlyCollection ids, RelationshipAttribute[] relationshipsToNextLayer) where TResource : class, IIdentifiable { + if (!ids.Any()) + { + return Array.Empty(); + } + var resourceContext = _resourceContextProvider.GetResourceContext(); - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var filterExpression = CreateFilterByIds(ids, resourceContext); var queryLayer = new QueryLayer(resourceContext) { - Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), - ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) + Filter = filterExpression }; var chains = relationshipsToNextLayer.Select(relationship => new ResourceFieldChainExpression(relationship)).ToList(); @@ -151,6 +155,21 @@ private IEnumerable GetWhereAndInclude(IEnumerable(IReadOnlyCollection ids, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(idAttribute); + + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single().ToString()); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + + var constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList(); + return new EqualsAnyOfExpression(idChain, constants); + } + private IResourceReadRepository GetRepository() where TResource : class, IIdentifiable { return _genericProcessorFactory.Get>(typeof(IResourceReadRepository<,>), typeof(TResource), typeof(TId)); diff --git a/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs similarity index 94% rename from src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs rename to src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 7816c981e1..c49721c3eb 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Repositories /// /// Retrieves a instance from the D/I container and invokes a callback on it. /// - public interface IRepositoryAccessor + public interface IResourceRepositoryAccessor { /// /// Gets resources by filtering on id. diff --git a/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs similarity index 71% rename from src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs rename to src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index b868647944..23007ee801 100644 --- a/src/JsonApiDotNetCore/Repositories/RepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -13,29 +13,29 @@ namespace JsonApiDotNetCore.Repositories { /// - public class RepositoryAccessor : IRepositoryAccessor + public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); private static readonly MethodInfo _openGetByIdMethod; - static RepositoryAccessor() + static ResourceRepositoryAccessor() { - _openGetByIdMethod = typeof(RepositoryAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); + _openGetByIdMethod = typeof(ResourceRepositoryAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); } private readonly IServiceProvider _serviceProvider; - private readonly IResourceContextProvider _provider; + private readonly IResourceContextProvider _resourceContextProvider; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly Dictionary _parameterizedMethodRepositoryCache = new Dictionary(); - public RepositoryAccessor( + public ResourceRepositoryAccessor( IServiceProvider serviceProvider, - IResourceContextProvider provider, + IResourceContextProvider resourceContextProvider, IResourceDefinitionAccessor resourceDefinitionAccessor) { _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); - _provider = provider ?? throw new ArgumentException(nameof(serviceProvider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentException(nameof(serviceProvider)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentException(nameof(resourceDefinitionAccessor)); } @@ -43,7 +43,7 @@ public RepositoryAccessor( public async Task> GetResourcesByIdAsync(Type resourceType, IReadOnlyCollection ids) { - var resourceContext = _provider.GetResourceContext(resourceType); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); var (getByIdMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); var resources = await InvokeAsync(getByIdMethod, this, new [] { ids, repository, resourceContext }); @@ -69,19 +69,18 @@ public async Task> GetResourcesByIdAsync(Type resourc } private async Task> GetById( - IEnumerable ids, + IReadOnlyCollection ids, IResourceReadRepository repository, - ResourceContext resourceContext) + ResourceContext resourceContext) where TResource : class, IIdentifiable { var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - - var idExpressions = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToArray(); - var equalsAnyOfFilter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), idExpressions); - + + var idsFilter = CreateFilterByIds(ids, resourceContext); + var queryLayer = new QueryLayer(resourceContext) { - Filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, equalsAnyOfFilter) + Filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, idsFilter) }; // Only apply projection when there is no resource inheritance. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844. @@ -94,6 +93,21 @@ private async Task> GetById( return await repository.GetAsync(queryLayer); } + + private static FilterExpression CreateFilterByIds(IReadOnlyCollection ids, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(idAttribute); + + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single()); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + + var constants = ids.Select(id => new LiteralConstantExpression(id)).ToList(); + return new EqualsAnyOfExpression(idChain, constants); + } private async Task InvokeAsync(MethodInfo methodInfo, object target, object[] parameters) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2f5b95bd96..19986c58df 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -30,7 +30,7 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly IRepositoryAccessor _repositoryAccessor; + private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _provider; private readonly IResourceHookExecutor _hookExecutor; @@ -44,7 +44,7 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IRepositoryAccessor repositoryAccessor, + IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) @@ -492,11 +492,11 @@ private void AssertRelationshipIsToMany() private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (RelationshipAttribute, IReadOnlyCollection)[] assignments) { - var missingResourceErrors = new List(); + var missingResources = new List(); foreach (var (relationship, resources) in assignments) { - IReadOnlyCollection identifiers = (IReadOnlyCollection)resources.Select(i => i.GetTypedId().ToString()).ToArray(); + IReadOnlyCollection identifiers = resources.Select(i => i.GetTypedId().ToString()).ToArray(); var databaseResources = await _repositoryAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); var errorsInAssignment = resources @@ -507,12 +507,12 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (Re _provider.GetResourceContext(sr.GetType()).PublicName, sr.StringId)); - missingResourceErrors.AddRange(errorsInAssignment); + missingResources.AddRange(errorsInAssignment); } - if (missingResourceErrors.Any()) + if (missingResources.Any()) { - throw new ResourcesInRelationshipAssignmentsNotFoundException(missingResourceErrors); + throw new ResourcesInRelationshipAssignmentsNotFoundException(missingResources); } } @@ -556,7 +556,7 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IRepositoryAccessor repositoryAccessor, + IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index d6d8215fbc..6454ea2dcd 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -39,7 +39,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -154,7 +154,7 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IRepositoryAccessor repositoryAccessor, + IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider provider, IResourceHookExecutor hookExecutor = null) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 42c8305e24..c6abae20b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -223,7 +223,7 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi } [Fact] - public async Task Fails_When_Patching_Resource_ToOne_Relationship_With_Missing_Resource() + public async Task Fails_When_Patching_Resource_Relationships_With_Missing_Resources() { // Arrange var todoItem = _todoItemFaker.Generate(); diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index 9811da0045..c62247adfd 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -76,7 +76,7 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); - var resourceAccessor = new Mock().Object; + var resourceAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; From 07968fb96911911819b755ae3c45b95d19021a9e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 13:28:33 +0200 Subject: [PATCH 047/240] Cleanup RepositoryAccessor --- .../Repositories/IResourceRepository.cs | 20 ++-- .../IResourceRepositoryAccessor.cs | 9 +- .../ResourceRepositoryAccessor.cs | 102 +++--------------- .../Services/JsonApiResourceService.cs | 49 +++++++-- .../ServiceDiscoveryFacadeTests.cs | 4 +- 5 files changed, 75 insertions(+), 109 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index 81efe34e54..f5ae656556 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -2,20 +2,24 @@ namespace JsonApiDotNetCore.Repositories { - /// - public interface IResourceRepository - : IResourceRepository + /// + /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. + /// + /// The resource type. + public interface IResourceRepository + : IResourceRepository, IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable - { } + { + } /// /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. /// /// The resource type. /// The resource identifier type. - public interface IResourceRepository - : IResourceReadRepository, - IResourceWriteRepository + public interface IResourceRepository + : IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable - { } + { + } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index c49721c3eb..38e52e66f1 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -1,9 +1,8 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Repositories { @@ -13,10 +12,8 @@ namespace JsonApiDotNetCore.Repositories public interface IResourceRepositoryAccessor { /// - /// Gets resources by filtering on id. + /// Invokes for the specified resource type. /// - /// The type for which to create a repository. - /// The ids to filter on. - Task> GetResourcesByIdAsync(Type resourceType, IReadOnlyCollection ids); + Task> GetAsync(Type resourceType, QueryLayer layer); } } diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 23007ee801..63644f301f 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -1,13 +1,9 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Repositories @@ -15,106 +11,42 @@ namespace JsonApiDotNetCore.Repositories /// public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { - private static readonly Type _openResourceReadRepositoryType = typeof(IResourceReadRepository<,>); - private static readonly MethodInfo _openGetByIdMethod; - - static ResourceRepositoryAccessor() - { - _openGetByIdMethod = typeof(ResourceRepositoryAccessor).GetMethod(nameof(GetById), BindingFlags.NonPublic | BindingFlags.Instance); - } - private readonly IServiceProvider _serviceProvider; private readonly IResourceContextProvider _resourceContextProvider; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Dictionary _parameterizedMethodRepositoryCache = new Dictionary(); - - public ResourceRepositoryAccessor( - IServiceProvider serviceProvider, - IResourceContextProvider resourceContextProvider, - IResourceDefinitionAccessor resourceDefinitionAccessor) + public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentException(nameof(serviceProvider)); - _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentException(nameof(resourceDefinitionAccessor)); } /// - public async Task> GetResourcesByIdAsync(Type resourceType, - IReadOnlyCollection ids) + public async Task> GetAsync(Type resourceType, QueryLayer layer) { - var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - var (getByIdMethod, repository) = GetParameterizedMethodAndRepository(resourceType, resourceContext); - - var resources = await InvokeAsync(getByIdMethod, this, new [] { ids, repository, resourceContext }); - - return (IEnumerable) resources; - } - - private (MethodInfo, object) GetParameterizedMethodAndRepository(Type resourceType, - ResourceContext resourceContext) - { - if (!_parameterizedMethodRepositoryCache.TryGetValue(resourceType, out var accessorPair)) - { - var parameterizedMethod = _openGetByIdMethod.MakeGenericMethod(resourceType, resourceContext.IdentityType); - - var repositoryType = _openResourceReadRepositoryType.MakeGenericType(resourceType, resourceContext.IdentityType); - var repository = _serviceProvider.GetRequiredService(repositoryType); - - accessorPair = (parameterizedMethod, repository); - _parameterizedMethodRepositoryCache.Add(resourceType, accessorPair); - } + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (layer == null) throw new ArgumentNullException(nameof(layer)); - return accessorPair; + dynamic repository = GetRepository(resourceType); + return (IReadOnlyCollection) await repository.GetAsync(layer); } - private async Task> GetById( - IReadOnlyCollection ids, - IResourceReadRepository repository, - ResourceContext resourceContext) - where TResource : class, IIdentifiable + protected object GetRepository(Type resourceType) { - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - - var idsFilter = CreateFilterByIds(ids, resourceContext); - - var queryLayer = new QueryLayer(resourceContext) - { - Filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, idsFilter) - }; + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - // Only apply projection when there is no resource inheritance. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844. - // We can leave it out because the projection here is just an optimization - if (!resourceContext.ResourceType.IsAbstract) + if (resourceContext.IdentityType == typeof(int)) { - var projection = new Dictionary {{idAttribute, null}}; - queryLayer.Projection = projection; - } - - return await repository.GetAsync(queryLayer); - } - - private static FilterExpression CreateFilterByIds(IReadOnlyCollection ids, ResourceContext resourceContext) - { - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - var idChain = new ResourceFieldChainExpression(idAttribute); + var intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceContext.ResourceType); + var intRepository = _serviceProvider.GetService(intRepositoryType); - if (ids.Count == 1) - { - var constant = new LiteralConstantExpression(ids.Single()); - return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + if (intRepository != null) + { + return intRepository; + } } - var constants = ids.Select(id => new LiteralConstantExpression(id)).ToList(); - return new EqualsAnyOfExpression(idChain, constants); - } - - private async Task InvokeAsync(MethodInfo methodInfo, object target, object[] parameters) - { - dynamic task = methodInfo.Invoke(target, parameters); - await task; - - return task.GetAwaiter().GetResult(); + var resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 19986c58df..a83a4ca70b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -32,7 +32,7 @@ public class JsonApiResourceService : private readonly IResourceFactory _resourceFactory; private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly ITargetedFields _targetedFields; - private readonly IResourceContextProvider _provider; + private readonly IResourceContextProvider _resourceContextProvider; private readonly IResourceHookExecutor _hookExecutor; public JsonApiResourceService( @@ -46,7 +46,7 @@ public JsonApiResourceService( IResourceFactory resourceFactory, IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, - IResourceContextProvider provider, + IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -61,7 +61,7 @@ public JsonApiResourceService( _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _repositoryAccessor = repositoryAccessor ?? throw new ArgumentNullException(nameof(repositoryAccessor)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); - _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _hookExecutor = hookExecutor; } @@ -497,14 +497,14 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (Re foreach (var (relationship, resources) in assignments) { IReadOnlyCollection identifiers = resources.Select(i => i.GetTypedId().ToString()).ToArray(); - var databaseResources = await _repositoryAccessor.GetResourcesByIdAsync(relationship.RightType, identifiers); + var databaseResources = await GetResourcesByIdAsync(relationship.RightType, identifiers); var errorsInAssignment = resources .Where(sr => databaseResources.All(dbr => dbr.StringId != sr.StringId)) .Select(sr => new MissingResourceInRelationship( - _provider.GetResourceContext(relationship.RightType).PublicName, - _provider.GetResourceContext(sr.GetType()).PublicName, + _resourceContextProvider.GetResourceContext(relationship.RightType).PublicName, + _resourceContextProvider.GetResourceContext(sr.GetType()).PublicName, sr.StringId)); missingResources.AddRange(errorsInAssignment); @@ -516,6 +516,39 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (Re } } + private async Task> GetResourcesByIdAsync(Type resourceType, + IReadOnlyCollection ids) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + var idsFilter = CreateFilterByIds(ids, resourceContext); + + //var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + + var queryLayer = new QueryLayer(resourceContext) + { + // TODO: Call into ResourceDefinition.OnApplyFilter + //Filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, idsFilter) + Filter = idsFilter + }; + + return await _repositoryAccessor.GetAsync(resourceType, queryLayer); + } + + private static FilterExpression CreateFilterByIds(IReadOnlyCollection ids, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(idAttribute); + + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single()); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + + var constants = ids.Select(id => new LiteralConstantExpression(id)).ToList(); + return new EqualsAnyOfExpression(idChain, constants); + } + private List AsList(TResource resource) { return new List { resource }; @@ -558,10 +591,10 @@ public JsonApiResourceService( IResourceFactory resourceFactory, IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, - IResourceContextProvider provider, + IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, provider, hookExecutor) + resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, resourceContextProvider, hookExecutor) { } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 6454ea2dcd..1afd40f890 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -156,10 +156,10 @@ public TestModelService( IResourceFactory resourceFactory, IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, - IResourceContextProvider provider, + IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, provider, hookExecutor) + resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, resourceContextProvider, hookExecutor) { } } From bc87317ab0fc1e1cbc53c221f53089db29401325 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 14:04:57 +0200 Subject: [PATCH 048/240] TopFieldSelection --- .../Services/CustomArticleService.cs | 6 +- .../Services/JsonApiResourceService.cs | 59 +++++++++---------- .../ServiceDiscoveryFacadeTests.cs | 7 ++- .../Services/DefaultResourceService_Tests.cs | 7 ++- 4 files changed, 38 insertions(+), 41 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 0e1866e904..c82ca17572 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -15,6 +15,7 @@ public class CustomArticleService : JsonApiResourceService
{ public CustomArticleService( IResourceRepository
repository, + IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -22,12 +23,11 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, resourceContextProvider, hookExecutor) + : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index a83a4ca70b..b1dcab53f5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -23,6 +23,7 @@ public class JsonApiResourceService : where TResource : class, IIdentifiable { private readonly IResourceRepository _repository; + private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IPaginationContext _paginationContext; private readonly IJsonApiOptions _options; @@ -30,13 +31,13 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; private readonly IResourceHookExecutor _hookExecutor; public JsonApiResourceService( IResourceRepository repository, + IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -44,7 +45,6 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) @@ -52,6 +52,7 @@ public JsonApiResourceService( if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _repositoryAccessor = repositoryAccessor ?? throw new ArgumentNullException(nameof(repositoryAccessor)); _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -59,7 +60,6 @@ public JsonApiResourceService( _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _repositoryAccessor = repositoryAccessor ?? throw new ArgumentNullException(nameof(repositoryAccessor)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _hookExecutor = hookExecutor; @@ -107,7 +107,7 @@ public virtual async Task GetAsync(TId id) _hookExecutor?.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); - var primaryResource = await GetPrimaryResourceById(id, Projection.TopSparseFieldSet); + var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.PreserveExisting); if (_hookExecutor != null) { @@ -118,7 +118,6 @@ public virtual async Task GetAsync(TId id) return primaryResource; } - /// public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { @@ -213,7 +212,7 @@ public virtual async Task CreateAsync(TResource resource) throw; } - resource = await GetPrimaryResourceById(resource.Id, Projection.TopSparseFieldSet); + resource = await GetPrimaryResourceById(resource.Id, TopFieldSelection.PreserveExisting); if (_hookExecutor != null) { @@ -241,7 +240,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, } catch (DataStoreUpdateFailedException) { - var primaryResource = await GetPrimaryResourceById(id, Projection.PrimaryId); + var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); var relationshipAssignment = (_request.Relationship, secondaryResourceIds); @@ -258,7 +257,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR _traceWriter.LogMethodStart(new {id, resourceFromRequest}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); - TResource resourceFromDatabase = await GetPrimaryResourceById(id, Projection.None); + TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -287,7 +286,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR } _repository.FlushFromCache(resourceFromDatabase); - TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, Projection.None); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); @@ -306,7 +305,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, if (_hookExecutor != null) { - primaryResource = await GetPrimaryResourceById(id, Projection.PrimaryId); + primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); } @@ -319,7 +318,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, { if (primaryResource == null) { - primaryResource = await GetPrimaryResourceById(id, Projection.PrimaryId); + primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); } @@ -360,7 +359,7 @@ public virtual async Task DeleteAsync(TId id) catch (DataStoreUpdateFailedException) { succeeded = false; - resource = await GetPrimaryResourceById(id, Projection.PrimaryId); + resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(resource); throw; @@ -386,37 +385,33 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } catch (DataStoreUpdateFailedException) { - var resource = await GetPrimaryResourceById(id, Projection.PrimaryId); + var resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(resource); throw; } } - private async Task GetPrimaryResourceById(TId id, Projection projectionToAdd) + private async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) { var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); primaryLayer.Sort = null; primaryLayer.Pagination = null; primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); - if (projectionToAdd == Projection.None && primaryLayer.Projection != null) + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - // Discard any ?fields= or attribute exclusions from ResourceDefinition, because we need the full record. + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + primaryLayer.Projection = new Dictionary {{idAttribute, null}}; + } + else if (fieldSelection == TopFieldSelection.AllAttributes && primaryLayer.Projection != null) + { + // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full record. while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) { primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); } } - else if (projectionToAdd == Projection.PrimaryId) - { - // https://github.com/dotnet/efcore/issues/20502 - if (!TypeHelper.ConstructorDependsOnDbContext(_request.PrimaryResource.ResourceType)) - { - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - primaryLayer.Projection = new Dictionary { { idAttribute, null } }; - } - } var primaryResources = await _repository.GetAsync(primaryLayer); @@ -564,11 +559,11 @@ private IReadOnlyCollection AsReadOnlyCollection(object relations return (IReadOnlyCollection)relationshipAssignment; } - private enum Projection + private enum TopFieldSelection { - None, - PrimaryId, - TopSparseFieldSet + AllAttributes, + OnlyIdAttribute, + PreserveExisting } } @@ -582,6 +577,7 @@ public class JsonApiResourceService : JsonApiResourceService repository, + IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -589,12 +585,11 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, resourceContextProvider, hookExecutor) + : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 1afd40f890..e991398e8e 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -147,6 +147,7 @@ public class TestModelService : JsonApiResourceService { public TestModelService( IResourceRepository repository, + IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -154,12 +155,12 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceRepositoryAccessor repositoryAccessor, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, repositoryAccessor, targetedFields, resourceContextProvider, hookExecutor) + : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, + hookExecutor) { } } diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index c62247adfd..cb09668ea9 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -76,7 +76,7 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); - var resourceAccessor = new Mock().Object; + var repositoryAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; @@ -89,8 +89,9 @@ private JsonApiResourceService GetService() .Single(x => x.PublicName == "collection") }; - return new JsonApiResourceService(_repositoryMock.Object, composer, paginationContext, options, - NullLoggerFactory.Instance, request, changeTracker, resourceFactory, resourceAccessor, targetedFields, resourceContextProvider, null); + return new JsonApiResourceService(_repositoryMock.Object, repositoryAccessor, composer, + paginationContext, options, NullLoggerFactory.Instance, request, changeTracker, resourceFactory, + targetedFields, resourceContextProvider); } } } From aa8ba92494fabc46f81e464ee78adda2a5ac188a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 14:39:37 +0200 Subject: [PATCH 049/240] fixes and cleanup --- .../Data/AppDbContext.cs | 3 -- .../Models/Category.cs | 19 ++++++++--- ...elationshipAssignmentNotFoundException.cs} | 4 +-- .../Middleware/ExceptionHandler.cs | 2 +- ...ception.cs => DataStoreUpdateException.cs} | 4 +-- .../EntityFrameworkCoreRepository.cs | 33 ++++++++++--------- .../Serialization/FieldsToSerialize.cs | 11 +++---- .../Serialization/JsonApiReader.cs | 2 +- .../Services/JsonApiResourceService.cs | 23 +++++++------ .../Acceptance/Spec/UpdatingDataTests.cs | 3 +- .../Spec/UpdatingRelationshipsTests.cs | 11 ++++++- 11 files changed, 66 insertions(+), 49 deletions(-) rename src/JsonApiDotNetCore/Errors/{ResourcesInRelationshipAssignmentsNotFoundException.cs => ResourcesInRelationshipAssignmentNotFoundException.cs} (82%) rename src/JsonApiDotNetCore/Repositories/{DataStoreUpdateFailedException.cs => DataStoreUpdateException.cs} (68%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index a8a38daed7..94fba95824 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -22,10 +22,7 @@ public sealed class AppDbContext : DbContext public DbSet ArticleTags { get; set; } public DbSet Tags { get; set; } public DbSet Blogs { get; set; } - public DbSet Products { get; set; } - - public DbSet Categories { get; set; } public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs index 08695e15e2..e0382c193c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -11,13 +12,21 @@ public override string Id get => $"{CountryId}-{ShopId}"; set { - var split = value.Split('-'); - CountryId = int.Parse(split[0]); - ShopId = split[1]; - } + var elements = value.Split('-'); + if (elements.Length == 2) + { + if (int.TryParse(elements[0], out var countryId)) + { + CountryId = countryId; + ShopId = elements[1]; + return; + } + } + throw new InvalidOperationException($"Failed to convert ID '{value}'."); + } } - + [Attr] public string Name { get; set; } diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs similarity index 82% rename from src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs rename to src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs index 235f8de496..02f007187f 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs @@ -9,11 +9,11 @@ namespace JsonApiDotNetCore.Errors /// /// The error that is thrown when assigning one or more non-existing resources to a relationship. /// - public sealed class ResourcesInRelationshipAssignmentsNotFoundException : Exception + public sealed class ResourcesInRelationshipAssignmentNotFoundException : Exception { public IReadOnlyCollection Errors { get; } - public ResourcesInRelationshipAssignmentsNotFoundException(IEnumerable missingResources) + public ResourcesInRelationshipAssignmentNotFoundException(IEnumerable missingResources) { Errors = missingResources.Select(CreateError).ToList(); } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index b23c683288..f102f5ac7b 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,7 +71,7 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) return new ErrorDocument(modelStateException.Errors); } - if (exception is ResourcesInRelationshipAssignmentsNotFoundException + if (exception is ResourcesInRelationshipAssignmentNotFoundException resourcesInRelationshipAssignmentNotFound) { return new ErrorDocument(resourcesInRelationshipAssignmentNotFound.Errors); diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailedException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs similarity index 68% rename from src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailedException.cs rename to src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index ce487088c7..d5e05557a2 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailedException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCore.Repositories /// /// The error that is thrown when the underlying data store is unable to persist changes. /// - public sealed class DataStoreUpdateFailedException : Exception + public sealed class DataStoreUpdateException : Exception { - public DataStoreUpdateFailedException(Exception exception) + public DataStoreUpdateException(Exception exception) : base("Failed to persist changes in the underlying data store.", exception) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index c0b53e89fc..ce30de419a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -152,7 +152,7 @@ public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection(); resource.Id = id; @@ -304,15 +304,18 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute } else if (relationship is HasOneAttribute hasOneRelationship) { - var foreignKeyProperties = GetForeignKeys(hasOneRelationship); + var foreignKeyProperties = GetForeignKey(hasOneRelationship); if (foreignKeyProperties.Count() != 1) { // If the primary resource is the dependent side of a to-one relationship, there can be no FK // violations resulting from a the implicit removal. navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); } } - - await (navigationEntry?.LoadAsync() ?? Task.CompletedTask); + + if (navigationEntry != null) + { + await navigationEntry.LoadAsync(); + } } /// @@ -399,14 +402,14 @@ private async Task ApplyRelationshipAssignment(TResource primaryResource, Relati secondaryResourceId = secondaryResource.GetTypedId(); } - var foreignKeyProperties = GetForeignKeys(relationship); + var foreignKeyProperties = GetForeignKey(relationship); if (foreignKeyProperties.Count() == 1) { foreignKeyProperties.First().SetValue(primaryResource, secondaryResourceId); _dbContext.Entry(primaryResource).State = EntityState.Modified; } } - + relationship.SetValue(primaryResource, trackedRelationshipAssignment, _resourceFactory); } @@ -442,22 +445,22 @@ private object GetTrackedRelationshipAssignment(object relationshipAssignment, T return trackedRelationshipAssignment; } - private PropertyInfo[] GetForeignKeys(RelationshipAttribute relationship) + private PropertyInfo[] GetForeignKey(RelationshipAttribute relationship) { if (relationship is HasOneAttribute) { var entityMetadata = _dbContext.Model.FindEntityType(typeof(TResource)); var foreignKeyMetadata = entityMetadata.FindNavigation(relationship.Property.Name).ForeignKey; - var declaringEntiyType = foreignKeyMetadata.DeclaringEntityType.ClrType; + var declaringEntityType = foreignKeyMetadata.DeclaringEntityType.ClrType; - if (declaringEntiyType == typeof(TResource)) + if (declaringEntityType == typeof(TResource)) { return foreignKeyMetadata.Properties.Select(p => p.PropertyInfo).Where(pi => pi != null).ToArray(); } } - return new PropertyInfo[0]; + return Array.Empty(); } private IIdentifiable GetTrackedOrAttach(IIdentifiable resource) @@ -480,7 +483,7 @@ private async Task TrySave() } catch (DbUpdateException exception) { - throw new DataStoreUpdateFailedException(exception); + throw new DataStoreUpdateException(exception); } } } diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index 0a79c70b82..f9740904fc 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -37,7 +37,7 @@ public IReadOnlyCollection GetAttributes(Type resourceType, Relat if (_jsonApiRequest.Kind == EndpointKind.Relationship) { - return new AttrAttribute[0]; + return Array.Empty(); } var sparseFieldSetAttributes = _constraintProviders @@ -88,12 +88,9 @@ public IReadOnlyCollection GetRelationships(Type type) { if (type == null) throw new ArgumentNullException(nameof(type)); - if (_jsonApiRequest.Kind == EndpointKind.Relationship) - { - return new RelationshipAttribute[0]; - } - - return _resourceGraph.GetRelationships(type); + return _jsonApiRequest.Kind == EndpointKind.Relationship + ? Array.Empty() + : _resourceGraph.GetRelationships(type); } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index d84e2ff526..b1c0c9becf 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -183,7 +183,7 @@ private IEnumerable GetBodyResourceTypes(object model) return resourceCollection.Select(r => r.GetType()).Distinct(); } - return model == null ? new Type[0] : new[] { model.GetType() }; + return model == null ? Array.Empty() : new[] { model.GetType() }; } private Type GetEndpointResourceType() diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index b1dcab53f5..2507c997f9 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -204,7 +204,7 @@ public virtual async Task CreateAsync(TResource resource) { await _repository.CreateAsync(resource); } - catch (DataStoreUpdateFailedException) + catch (DataStoreUpdateException) { var assignments = GetPopulatedRelationshipAssignments(resource); await AssertResourcesInRelationshipAssignmentsExistAsync(assignments); @@ -238,7 +238,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, { await _repository.AddToToManyRelationshipAsync(id, secondaryResourceIds); } - catch (DataStoreUpdateFailedException) + catch (DataStoreUpdateException) { var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); @@ -271,7 +271,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR { await _repository.UpdateAsync(resourceFromRequest, resourceFromDatabase); } - catch (DataStoreUpdateFailedException) + catch (DataStoreUpdateException) { var relationshipAssignments = GetPopulatedRelationshipAssignments(resourceFromRequest); await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignments); @@ -314,7 +314,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, { await _repository.SetRelationshipAsync(id, secondaryResourceIds); } - catch (DataStoreUpdateFailedException) + catch (DataStoreUpdateException) { if (primaryResource == null) { @@ -356,7 +356,7 @@ public virtual async Task DeleteAsync(TId id) { await _repository.DeleteAsync(id); } - catch (DataStoreUpdateFailedException) + catch (DataStoreUpdateException) { succeeded = false; resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); @@ -383,7 +383,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN { await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); } - catch (DataStoreUpdateFailedException) + catch (DataStoreUpdateException) { var resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(resource); @@ -401,8 +401,11 @@ private async Task GetPrimaryResourceById(TId id, TopFieldSelection f if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - primaryLayer.Projection = new Dictionary {{idAttribute, null}}; + if (!TypeHelper.ConstructorDependsOnDbContext(_request.PrimaryResource.ResourceType)) + { + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + primaryLayer.Projection = new Dictionary {{idAttribute, null}}; + } } else if (fieldSelection == TopFieldSelection.AllAttributes && primaryLayer.Projection != null) { @@ -498,7 +501,7 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (Re .Where(sr => databaseResources.All(dbr => dbr.StringId != sr.StringId)) .Select(sr => new MissingResourceInRelationship( - _resourceContextProvider.GetResourceContext(relationship.RightType).PublicName, + relationship.PublicName, _resourceContextProvider.GetResourceContext(sr.GetType()).PublicName, sr.StringId)); @@ -507,7 +510,7 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (Re if (missingResources.Any()) { - throw new ResourcesInRelationshipAssignmentsNotFoundException(missingResources); + throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index ab7c29d7cf..4df27b6b3c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -483,10 +483,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Keys() + public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key() { // Arrange - var product = new Product { Name = "Croissants" diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index c6abae20b0..3192ba0244 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -282,9 +282,18 @@ public async Task Fails_When_Patching_Resource_Relationships_With_Missing_Resour AssertEqualStatusCode(HttpStatusCode.NotFound, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Equal(3, errorDocument.Errors.Count); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'people' with ID '900000' being assigned to relationship 'people' does not exist.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'people' with ID '900000' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[0].Detail); + + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[1].StatusCode); + Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[1].Title); + Assert.Equal("Resource of type 'people' with ID '900001' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[1].Detail); + + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[2].StatusCode); + Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[2].Title); + Assert.Equal("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist.",errorDocument.Errors[2].Detail); } [Fact] From 92f9eefcc8cb9fe73aa36c4d653645c835418e15 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 13 Oct 2020 15:43:32 +0200 Subject: [PATCH 050/240] test: test cases for set RequestRelationship no longer relevant --- .../Server/ResponseSerializerTests.cs | 78 ------------------- 1 file changed, 78 deletions(-) diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 732b74d71c..9749d95ac9 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -349,84 +349,6 @@ public void SerializeSingle_NullWithLinksAndMeta_StillShowsLinksAndMeta() Assert.Equal(expected, serialized); } - [Fact] - public void SerializeSingleWithRequestRelationship_NullToOneRelationship_CanSerialize() - { - // Arrange - var resource = new OneToOnePrincipal { Id = 2, Dependent = null }; - var serializer = GetResponseSerializer(); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ ""data"": null}"; - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingleWithRequestRelationship_PopulatedToOneRelationship_CanSerialize() - { - // Arrange - var resource = new OneToOnePrincipal { Id = 2, Dependent = new OneToOneDependent { Id = 1 } }; - var serializer = GetResponseSerializer(); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ - ""data"":{ - ""type"":""oneToOneDependents"", - ""id"":""1"" - } - }"; - - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSerialize() - { - // Arrange - var resource = new OneToManyPrincipal { Id = 2, Dependents = new HashSet() }; - var serializer = GetResponseSerializer(); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ ""data"": [] }"; - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_CanSerialize() - { - // Arrange - var resource = new OneToManyPrincipal { Id = 2, Dependents = new HashSet { new OneToManyDependent { Id = 1 } } }; - var serializer = GetResponseSerializer(); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ - ""data"":[{ - ""type"":""oneToManyDependents"", - ""id"":""1"" - }] - }"; - - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - - Assert.Equal(expected, serialized); - } - [Fact] public void SerializeError_Error_CanSerialize() { From 1bf957e6b537cddda9d8db46f75b906ff2c0031d Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 13 Oct 2020 16:28:21 +0200 Subject: [PATCH 051/240] tests: missing resources in relationship endpoint tests --- .../Spec/UpdatingRelationshipsTests.cs | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 3192ba0244..c4c0cadd20 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -223,7 +223,7 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi } [Fact] - public async Task Fails_When_Patching_Resource_Relationships_With_Missing_Resources() + public async Task Fails_When_Patching_Primary_Endpoint_With_Missing_Secondary_Resources() { // Arrange var todoItem = _todoItemFaker.Generate(); @@ -966,7 +966,7 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En } [Fact] - public async Task Fails_When_Unknown_Relationship_On_Relationship_Endpoint() + public async Task Fails_When_Patching_Relationships_Endpoint_With_Unknown_Relationship() { // Arrange var person = _personFaker.Generate(); @@ -1006,7 +1006,7 @@ public async Task Fails_When_Unknown_Relationship_On_Relationship_Endpoint() } [Fact] - public async Task Fails_When_Missing_Resource_On_Relationship_Endpoint() + public async Task Fails_When_Patching_Relationships_Endpoint_With_Missing_Primary_Resource() { // Arrange var person = _personFaker.Generate(); @@ -1042,6 +1042,94 @@ public async Task Fails_When_Missing_Resource_On_Relationship_Endpoint() errorDocument.Errors[0].Detail); } + [Fact] + public async Task Fails_When_Posting_To_Many_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resources() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + var person = _personFaker.Generate(); + _context.People.Add(person); + + await _context.SaveChangesAsync(); + + var missingPerson1 = _personFaker.Generate(); + missingPerson1.Id = 9999998; + var missingPerson2 = _personFaker.Generate(); + missingPerson2.Id = 9999999; + + var builder = WebHost.CreateDefaultBuilder().UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(new [] { person, missingPerson1, missingPerson2 }); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/stakeHolders"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Equal(2, errorDocument.Errors.Count); + + + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'people' with ID '9999998' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[0].Detail); + + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[1].StatusCode); + Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[1].Title); + Assert.Equal("Resource of type 'people' with ID '9999999' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[1].Detail); + } + + [Fact] + public async Task Fails_When_Patching_To_One_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resource() +{ + // Arrange + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var missingPerson = _personFaker.Generate(); + missingPerson.Id = 9999999; + + var builder = WebHost.CreateDefaultBuilder().UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(missingPerson); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + AssertEqualStatusCode(HttpStatusCode.NotFound, response); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'people' with ID '9999999' being assigned to relationship 'owner' does not exist.",errorDocument.Errors[0].Detail); +} + private void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) { Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); From 790734bf904d2ccd1e8398d7ebf9185516603e20 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 16:55:08 +0200 Subject: [PATCH 052/240] Refactored mising resources in relationships at service layer --- .../Controllers/BaseJsonApiController.cs | 2 +- .../Expressions/EqualsAnyOfExpression.cs | 1 - .../EntityFrameworkCoreRepository.cs | 12 +- ...xtensions.cs => IdentifiableExtensions.cs} | 8 +- .../Services/AsyncCollectionExtensions.cs | 28 +++ .../Services/ISetRelationshipService.cs | 2 +- .../Services/JsonApiResourceService.cs | 193 +++++++++--------- .../QueryStringParameters/FilterParseTests.cs | 1 + 8 files changed, 139 insertions(+), 108 deletions(-) rename src/JsonApiDotNetCore/Resources/{IIdentifiableExtensions.cs => IdentifiableExtensions.cs} (65%) create mode 100644 src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index a6c08171f7..12e5551bd1 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -224,7 +224,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource /// /// The identifier of the primary resource. /// The relationship for which to perform a complete replacement. - /// The resources to assign to the relationship. + /// The resource or set of resources to assign to the relationship. public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs index 07e78c1595..134d8cebdd 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -22,7 +22,6 @@ public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, if (constants.Count < 2) { - // TODO: Update tests. throw new ArgumentException("At least two constants are required.", nameof(constants)); } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index ce30de419a..65e213901e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -221,10 +221,10 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< await LoadRelationship(primaryResource, relationship); - var currentRelationshipAssignment = ((IReadOnlyCollection)relationship.GetValue(primaryResource)); + var currentRelationshipAssignment = (IReadOnlyCollection)relationship.GetValue(primaryResource); var newRelationshipAssignment = currentRelationshipAssignment.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToArray(); - if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count()) + if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count) { await ApplyRelationshipAssignment(primaryResource, relationship, newRelationshipAssignment); await TrySave(); @@ -305,7 +305,7 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute else if (relationship is HasOneAttribute hasOneRelationship) { var foreignKeyProperties = GetForeignKey(hasOneRelationship); - if (foreignKeyProperties.Count() != 1) + if (foreignKeyProperties.Length != 1) { // If the primary resource is the dependent side of a to-one relationship, there can be no FK // violations resulting from a the implicit removal. navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); @@ -403,7 +403,7 @@ private async Task ApplyRelationshipAssignment(TResource primaryResource, Relati } var foreignKeyProperties = GetForeignKey(relationship); - if (foreignKeyProperties.Count() == 1) + if (foreignKeyProperties.Length == 1) { foreignKeyProperties.First().SetValue(primaryResource, secondaryResourceId); _dbContext.Entry(primaryResource).State = EntityState.Modified; @@ -427,8 +427,8 @@ private object GetTrackedRelationshipAssignment(object relationshipAssignment, T } else { - var hasManyValue = ((IReadOnlyCollection)relationshipAssignment); - var trackedHasManyValues = new object[hasManyValue.Count()]; + var hasManyValue = (IReadOnlyCollection)relationshipAssignment; + var trackedHasManyValues = new object[hasManyValue.Count]; for (int i = 0; i < hasManyValue.Count; i++) { diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs similarity index 65% rename from src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs rename to src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index a77d8d422a..1b62fe81af 100644 --- a/src/JsonApiDotNetCore/Resources/IIdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,10 +1,9 @@ using System; using System.Reflection; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Resources { - public static class IIdentifiableExtensions + public static class IdentifiableExtensions { internal static object GetTypedId(this IIdentifiable identifiable) { @@ -12,6 +11,11 @@ internal static object GetTypedId(this IIdentifiable identifiable) PropertyInfo property = identifiable.GetType().GetProperty(nameof(Identifiable.Id)); + if (property == null) + { + throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an Id property."); + } + return property.GetValue(identifiable); } } diff --git a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs new file mode 100644 index 0000000000..0a400e5512 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.Services +{ + public static class AsyncCollectionExtensions + { + public static async Task AddRangeAsync(this ICollection source, IAsyncEnumerable elementsToAdd) + { + await foreach (var missingResource in elementsToAdd) + { + source.Add(missingResource); + } + } + + public static async Task> ToListAsync(this IAsyncEnumerable source) + { + var list = new List(); + + await foreach (var element in source) + { + list.Add(element); + } + + return list; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index e26de0645c..af34622f4b 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -16,7 +16,7 @@ public interface ISetRelationshipService where TResource : cl /// /// The identifier of the primary resource. /// The relationship for which to perform a complete replacement. - /// The resources to assign to the relationship. + /// The resource or set of resources to assign to the relationship. Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2507c997f9..108b9c07f6 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -111,8 +111,8 @@ public virtual async Task GetAsync(TId id) if (_hookExecutor != null) { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetSingle); - return _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetSingle).Single(); + _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetSingle); + return _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetSingle).Single(); } return primaryResource; @@ -144,8 +144,8 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN if (_hookExecutor != null) { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); + _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); + primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); } var secondaryResource = _request.Relationship.GetValue(primaryResource); @@ -182,8 +182,8 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh if (_hookExecutor != null) { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); + _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); + primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); } return _request.Relationship.GetValue(primaryResource); @@ -197,7 +197,7 @@ public virtual async Task CreateAsync(TResource resource) if (_hookExecutor != null) { - resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); + resource = _hookExecutor.BeforeCreate(ToList(resource), ResourcePipeline.Post).Single(); } try @@ -206,9 +206,7 @@ public virtual async Task CreateAsync(TResource resource) } catch (DataStoreUpdateException) { - var assignments = GetPopulatedRelationshipAssignments(resource); - await AssertResourcesInRelationshipAssignmentsExistAsync(assignments); - + await AssertResourcesInRelationshipAssignmentsExistAsync(_targetedFields.Relationships, resource); throw; } @@ -216,8 +214,8 @@ public virtual async Task CreateAsync(TResource resource) if (_hookExecutor != null) { - _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); - resource = _hookExecutor.OnReturn(AsList(resource), ResourcePipeline.Post).Single(); + _hookExecutor.AfterCreate(ToList(resource), ResourcePipeline.Post); + resource = _hookExecutor.OnReturn(ToList(resource), ResourcePipeline.Post).Single(); } return resource; @@ -243,9 +241,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); - var relationshipAssignment = (_request.Relationship, secondaryResourceIds); - await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignment); - + await AssertResourcesInRelationshipAssignmentExistAsync(_request.Relationship, secondaryResourceIds); throw; } } @@ -264,7 +260,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR if (_hookExecutor != null) { - resourceFromRequest = _hookExecutor.BeforeUpdate(AsList(resourceFromRequest), ResourcePipeline.Patch).Single(); + resourceFromRequest = _hookExecutor.BeforeUpdate(ToList(resourceFromRequest), ResourcePipeline.Patch).Single(); } try @@ -273,16 +269,14 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR } catch (DataStoreUpdateException) { - var relationshipAssignments = GetPopulatedRelationshipAssignments(resourceFromRequest); - await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignments); - + await AssertResourcesInRelationshipAssignmentsExistAsync(_targetedFields.Relationships, resourceFromRequest); throw; } - + if (_hookExecutor != null) { - _hookExecutor.AfterUpdate(AsList(resourceFromDatabase), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(resourceFromDatabase), ResourcePipeline.Patch); + _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); + _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); } _repository.FlushFromCache(resourceFromDatabase); @@ -307,7 +301,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, { primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); - _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + _hookExecutor.BeforeUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); } try @@ -321,19 +315,14 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); } - - if (secondaryResourceIds != null) - { - var relationshipAssignment = (_request.Relationship, AsReadOnlyCollection(secondaryResourceIds)); - await AssertResourcesInRelationshipAssignmentsExistAsync(relationshipAssignment); - } - + + await AssertResourcesInRelationshipAssignmentExistAsync(_request.Relationship, secondaryResourceIds); throw; } if (_hookExecutor != null && primaryResource != null) { - _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + _hookExecutor.AfterUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); } } @@ -347,7 +336,7 @@ public virtual async Task DeleteAsync(TId id) { resource = _resourceFactory.CreateInstance(); resource.Id = id; - _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); + _hookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); } var succeeded = true; @@ -366,7 +355,7 @@ public virtual async Task DeleteAsync(TId id) } finally { - _hookExecutor?.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); + _hookExecutor?.AfterDelete(ToList(resource), ResourcePipeline.Delete, succeeded); } } @@ -436,103 +425,97 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); } - private (RelationshipAttribute Relationship, IReadOnlyCollection Assignment)[] GetPopulatedRelationshipAssignments(TResource requestResource) + private async Task AssertResourcesInRelationshipAssignmentsExistAsync(IEnumerable relationships, TResource resource) { - var assignments = _targetedFields.Relationships - .Select(relationship => (Relationship: relationship, Assignment: AsReadOnlyCollection(relationship.GetValue(requestResource)))) - .Where(p => RelationshipIsPopulated(p.Assignment)) - .ToArray(); - - return assignments; - } + var missingResources = new List(); - private bool RelationshipIsPopulated(object assignment) - { - if (assignment is IIdentifiable hasOneValue) + foreach (var relationship in relationships) { - return true; + var missingResourcesInRelationship = GetMissingResourcesInRelationshipAsync(relationship, resource); + await missingResources.AddRangeAsync(missingResourcesInRelationship); } - else if (assignment is IReadOnlyCollection hasManyValues) - { - return hasManyValues.Any(); - } - else + + if (missingResources.Any()) { - return false; + throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); } } - private void AssertPrimaryResourceExists(TResource resource) + private async Task AssertResourcesInRelationshipAssignmentExistAsync(RelationshipAttribute relationship, object secondaryResourceIds) { - if (resource == null) + ICollection rightResources = ExtractResources(secondaryResourceIds); + + var missingResources = await GetMissingResourcesInRelationshipAsync(relationship, rightResources).ToListAsync(); + if (missingResources.Any()) { - throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName); + throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); } } - private void AssertRelationshipExists(string relationshipName) + private IAsyncEnumerable GetMissingResourcesInRelationshipAsync( + RelationshipAttribute relationship, TResource leftResource) { - var relationship = _request.Relationship; - if (relationship == null) - { - throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); - } + object rightValue = relationship.GetValue(leftResource); + ICollection rightResources = ExtractResources(rightValue); + + return GetMissingResourcesInRelationshipAsync(relationship, rightResources); } - private void AssertRelationshipIsToMany() + private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( + RelationshipAttribute relationship, ICollection rightResources) { - var relationship = _request.Relationship; - if (relationship is HasOneAttribute) + if (rightResources.Any()) { - throw new ToOneRelationshipUpdateForbiddenException(relationship.PublicName); + var rightIds = rightResources.Select(resource => resource.GetTypedId()); + var existingResourceStringIds = await GetSecondaryResourceStringIdsAsync(relationship.RightType, rightIds); + + foreach (var rightResource in rightResources) + { + if (existingResourceStringIds.Contains(rightResource.StringId)) + { + continue; + } + + var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); + + yield return new MissingResourceInRelationship(relationship.PublicName, + resourceContext.PublicName, rightResource.StringId); + } } } - private async Task AssertResourcesInRelationshipAssignmentsExistAsync(params (RelationshipAttribute, IReadOnlyCollection)[] assignments) + private static ICollection ExtractResources(object value) { - var missingResources = new List(); - - foreach (var (relationship, resources) in assignments) + if (value is IEnumerable resources) { - IReadOnlyCollection identifiers = resources.Select(i => i.GetTypedId().ToString()).ToArray(); - var databaseResources = await GetResourcesByIdAsync(relationship.RightType, identifiers); - - var errorsInAssignment = resources - .Where(sr => databaseResources.All(dbr => dbr.StringId != sr.StringId)) - .Select(sr => - new MissingResourceInRelationship( - relationship.PublicName, - _resourceContextProvider.GetResourceContext(sr.GetType()).PublicName, - sr.StringId)); - - missingResources.AddRange(errorsInAssignment); + return resources.ToList(); } - if (missingResources.Any()) - { - throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); + if (value is IIdentifiable resource) + { + return new[] {resource}; } + + return Array.Empty(); } - private async Task> GetResourcesByIdAsync(Type resourceType, - IReadOnlyCollection ids) + private async Task> GetSecondaryResourceStringIdsAsync(Type resourceType, IEnumerable typedIds) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - var idsFilter = CreateFilterByIds(ids, resourceContext); - //var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var idValues = typedIds.Select(id => id.ToString()).ToArray(); + var idsFilter = CreateFilterByIds(idValues, resourceContext); var queryLayer = new QueryLayer(resourceContext) { - // TODO: Call into ResourceDefinition.OnApplyFilter - //Filter = _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, idsFilter) Filter = idsFilter }; - return await _repositoryAccessor.GetAsync(resourceType, queryLayer); + var resources = await _repositoryAccessor.GetAsync(resourceType, queryLayer); + return resources.Select(resource => resource.StringId).ToArray(); } - private static FilterExpression CreateFilterByIds(IReadOnlyCollection ids, ResourceContext resourceContext) + private static FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext) { var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); var idChain = new ResourceFieldChainExpression(idAttribute); @@ -547,19 +530,35 @@ private static FilterExpression CreateFilterByIds(IReadOnlyCollection id return new EqualsAnyOfExpression(idChain, constants); } - private List AsList(TResource resource) + private void AssertPrimaryResourceExists(TResource resource) { - return new List { resource }; + if (resource == null) + { + throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName); + } } - private IReadOnlyCollection AsReadOnlyCollection(object relationshipAssignment) + private void AssertRelationshipExists(string relationshipName) { - if (relationshipAssignment is IIdentifiable hasOneAssignment) + var relationship = _request.Relationship; + if (relationship == null) { - return new[] { hasOneAssignment }; + throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } + } - return (IReadOnlyCollection)relationshipAssignment; + private void AssertRelationshipIsToMany() + { + var relationship = _request.Relationship; + if (relationship is HasOneAttribute) + { + throw new ToOneRelationshipUpdateForbiddenException(relationship.PublicName); + } + } + + private List ToList(TResource resource) + { + return new List { resource }; } private enum TopFieldSelection diff --git a/test/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/UnitTests/QueryStringParameters/FilterParseTests.cs index dc8b74411b..3f1f272066 100644 --- a/test/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -80,6 +80,7 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [InlineData("filter", "any(null,'a','b')", "Attribute 'null' does not exist on resource 'blogs'.")] [InlineData("filter", "any('a','b','c')", "Field name expected.")] [InlineData("filter", "any(title,'b','c',)", "Value between quotes expected.")] + [InlineData("filter", "any(title,'b')", ", expected.")] [InlineData("filter[articles]", "any(author,'a','b')", "Attribute 'author' does not exist on resource 'articles'.")] [InlineData("filter", "and(", "Filter function expected.")] [InlineData("filter", "or(equals(title,'some'),equals(title,'other')", ") expected.")] From 180779f5d1be4a3b526c0674b7dffd4bb74bbbb9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 17:34:00 +0200 Subject: [PATCH 053/240] plural naming because it may affect multiple relationships --- ...lationshipAssignmentsNotFoundException.cs} | 6 +++--- .../Middleware/ExceptionHandler.cs | 2 +- .../Services/JsonApiResourceService.cs | 20 +++++++------------ 3 files changed, 11 insertions(+), 17 deletions(-) rename src/JsonApiDotNetCore/Errors/{ResourcesInRelationshipAssignmentNotFoundException.cs => ResourcesInRelationshipAssignmentsNotFoundException.cs} (79%) diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs similarity index 79% rename from src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs rename to src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs index 02f007187f..1dcea4d112 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipAssignmentsNotFoundException.cs @@ -7,13 +7,13 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when assigning one or more non-existing resources to a relationship. + /// The error that is thrown when assigning one or more non-existing resources in one or more relationships. /// - public sealed class ResourcesInRelationshipAssignmentNotFoundException : Exception + public sealed class ResourcesInRelationshipAssignmentsNotFoundException : Exception { public IReadOnlyCollection Errors { get; } - public ResourcesInRelationshipAssignmentNotFoundException(IEnumerable missingResources) + public ResourcesInRelationshipAssignmentsNotFoundException(IEnumerable missingResources) { Errors = missingResources.Select(CreateError).ToList(); } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index f102f5ac7b..b23c683288 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,7 +71,7 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) return new ErrorDocument(modelStateException.Errors); } - if (exception is ResourcesInRelationshipAssignmentNotFoundException + if (exception is ResourcesInRelationshipAssignmentsNotFoundException resourcesInRelationshipAssignmentNotFound) { return new ErrorDocument(resourcesInRelationshipAssignmentNotFound.Errors); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 108b9c07f6..68fbb5ec5d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -425,19 +425,22 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); } - private async Task AssertResourcesInRelationshipAssignmentsExistAsync(IEnumerable relationships, TResource resource) + private async Task AssertResourcesInRelationshipAssignmentsExistAsync(IEnumerable relationships, TResource leftResource) { var missingResources = new List(); foreach (var relationship in relationships) { - var missingResourcesInRelationship = GetMissingResourcesInRelationshipAsync(relationship, resource); + object rightValue = relationship.GetValue(leftResource); + ICollection rightResources = ExtractResources(rightValue); + + var missingResourcesInRelationship = GetMissingResourcesInRelationshipAsync(relationship, rightResources); await missingResources.AddRangeAsync(missingResourcesInRelationship); } if (missingResources.Any()) { - throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); + throw new ResourcesInRelationshipAssignmentsNotFoundException(missingResources); } } @@ -448,19 +451,10 @@ private async Task AssertResourcesInRelationshipAssignmentExistAsync(Relationshi var missingResources = await GetMissingResourcesInRelationshipAsync(relationship, rightResources).ToListAsync(); if (missingResources.Any()) { - throw new ResourcesInRelationshipAssignmentNotFoundException(missingResources); + throw new ResourcesInRelationshipAssignmentsNotFoundException(missingResources); } } - private IAsyncEnumerable GetMissingResourcesInRelationshipAsync( - RelationshipAttribute relationship, TResource leftResource) - { - object rightValue = relationship.GetValue(leftResource); - ICollection rightResources = ExtractResources(rightValue); - - return GetMissingResourcesInRelationshipAsync(relationship, rightResources); - } - private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( RelationshipAttribute relationship, ICollection rightResources) { From 9adeab97d6e08ff159df17055bd8eae49dcc49d8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 13 Oct 2020 18:08:51 +0200 Subject: [PATCH 054/240] fixed broken build --- src/JsonApiDotNetCore/Resources/IResourceFactory.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index fcd3dd4007..80d4b82af4 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -16,7 +16,6 @@ public interface IResourceFactory /// /// Creates a new resource object instance. /// - /// The id that will be set for the instance, if provided. public TResource CreateInstance() where TResource : IIdentifiable; /// From 8f2b3319498615212e784edf9d990f105000cb48 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Oct 2020 10:16:57 +0200 Subject: [PATCH 055/240] more cleanup --- .../ToManyRelationshipRequiredException.cs | 20 ++++++ ...OneRelationshipUpdateForbiddenException.cs | 18 ----- .../Repositories/DbContextExtensions.cs | 25 ------- .../EntityFrameworkCoreRepository.cs | 18 ++--- .../Repositories/SafeTransactionProxy.cs | 68 ------------------- .../Services/JsonApiResourceService.cs | 4 +- 6 files changed, 31 insertions(+), 122 deletions(-) create mode 100644 src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs delete mode 100644 src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs delete mode 100644 src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs diff --git a/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs b/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs new file mode 100644 index 0000000000..5682ef9679 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when an attempt is made to update a to-one relationship from a to-many relationship endpoint. + /// + public sealed class ToManyRelationshipRequiredException : JsonApiException + { + public ToManyRelationshipRequiredException(string relationshipName) + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Only to-many relationships can be updated through this endpoint.", + Detail = $"Relationship '{relationshipName}' must be a to-many relationship." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs b/src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs deleted file mode 100644 index 3684ab2075..0000000000 --- a/src/JsonApiDotNetCore/Errors/ToOneRelationshipUpdateForbiddenException.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when an attempt is made to update a to-one relationship on a to-many relationship endpoint. - /// - public sealed class ToOneRelationshipUpdateForbiddenException : JsonApiException - { - public ToOneRelationshipUpdateForbiddenException(string toOneRelationship) - : base(new Error(HttpStatusCode.Forbidden) - { - Title = "The request to update the relationship is forbidden.", - Detail = $"Relationship {toOneRelationship} is not a to-many relationship." - }) { } - } -} diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 838d2b281a..69b9c12624 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,9 +1,7 @@ using System; using System.Linq; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; namespace JsonApiDotNetCore.Repositories { @@ -22,28 +20,5 @@ internal static object GetTrackedIdentifiable(this DbContext context, IIdentifia return entityEntry?.Entity; } - - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - /// - /// - /// - /// using(var transaction = _context.GetCurrentOrCreateTransaction()) - /// { - /// // perform multiple operations on the context and then save... - /// _context.SaveChanges(); - /// } - /// - /// - public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) - { - if (context == null) throw new ArgumentNullException(nameof(context)); - - return await SafeTransactionProxy.GetOrCreateAsync(context.Database); - } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 65e213901e..c292da357a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -137,12 +137,12 @@ public virtual async Task CreateAsync(TResource resource) _dbContext.Set().Add(resource); - await TrySave(); + await SaveChangesAsync(); FlushFromCache(resource); - // this ensures relationships get reloaded from the database if they have - // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + // This ensures relationships get reloaded from the database if they have + // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343. DetachRelationships(resource); } @@ -156,7 +156,7 @@ public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection @@ -197,7 +197,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r await ApplyRelationshipAssignment(resourceFromDatabase, relationship, relationshipAssignment); } - await TrySave(); + await SaveChangesAsync(); } /// @@ -208,7 +208,7 @@ public virtual async Task DeleteAsync(TId id) var resource = GetTrackedOrAttach(CreateInstanceWithAssignedId(id)); _dbContext.Remove(resource); - await TrySave(); + await SaveChangesAsync(); } public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) @@ -227,7 +227,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count) { await ApplyRelationshipAssignment(primaryResource, relationship, newRelationshipAssignment); - await TrySave(); + await SaveChangesAsync(); } } @@ -475,7 +475,7 @@ private IIdentifiable GetTrackedOrAttach(IIdentifiable resource) return trackedResource; } - private async Task TrySave() + private async Task SaveChangesAsync() { try { diff --git a/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs b/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs deleted file mode 100644 index 36c1e39b40..0000000000 --- a/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - internal class SafeTransactionProxy : IDbContextTransaction - { - private readonly bool _shouldExecute; - private readonly IDbContextTransaction _transaction; - - private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) - { - _transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); - _shouldExecute = shouldExecute; - } - - public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) - { - if (databaseFacade == null) throw new ArgumentNullException(nameof(databaseFacade)); - - return databaseFacade.CurrentTransaction != null - ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) - : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); - } - - /// - public Guid TransactionId => _transaction.TransactionId; - - /// - public void Commit() => Proxy(t => t.Commit()); - - /// - public void Rollback() => Proxy(t => t.Rollback()); - - /// - public void Dispose() => Proxy(t => t.Dispose()); - - private void Proxy(Action action) - { - if(_shouldExecute) - action(_transaction); - } - - public Task CommitAsync(CancellationToken cancellationToken = default) - { - return _transaction.CommitAsync(cancellationToken); - } - - public Task RollbackAsync(CancellationToken cancellationToken = default) - { - return _transaction.RollbackAsync(cancellationToken); - } - - public ValueTask DisposeAsync() - { - return _transaction.DisposeAsync(); - } - } -} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 68fbb5ec5d..7419d568dc 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -544,9 +544,9 @@ private void AssertRelationshipExists(string relationshipName) private void AssertRelationshipIsToMany() { var relationship = _request.Relationship; - if (relationship is HasOneAttribute) + if (!(relationship is HasManyAttribute)) { - throw new ToOneRelationshipUpdateForbiddenException(relationship.PublicName); + throw new ToManyRelationshipRequiredException(relationship.PublicName); } } From 6058e5a67f3a97a2e47e9e8f9a26134f1d5a3cab Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Oct 2020 12:04:12 +0200 Subject: [PATCH 056/240] repository renames --- .../Repositories/DbContextExtensions.cs | 16 +++- .../EntityFrameworkCoreRepository.cs | 95 +++++++++---------- 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 69b9c12624..3642094984 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -7,12 +7,12 @@ namespace JsonApiDotNetCore.Repositories { public static class DbContextExtensions { - internal static object GetTrackedIdentifiable(this DbContext context, IIdentifiable identifiable) + internal static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); var entityType = identifiable.GetType(); - var entityEntry = context.ChangeTracker + var entityEntry = dbContext.ChangeTracker .Entries() .FirstOrDefault(entry => entry.Entity.GetType() == entityType && @@ -20,5 +20,17 @@ internal static object GetTrackedIdentifiable(this DbContext context, IIdentifia return entityEntry?.Entity; } + + public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) + { + var trackedIdentifiable = (IIdentifiable)dbContext.GetTrackedIdentifiable(resource); + if (trackedIdentifiable == null) + { + dbContext.Entry(resource).State = EntityState.Unchanged; + trackedIdentifiable = resource; + } + + return trackedIdentifiable; + } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index c292da357a..724678ac4e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -131,8 +131,8 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationship in _targetedFields.Relationships) { - var relationshipAssignment = relationship.GetValue(resource); - await ApplyRelationshipAssignment(resource, relationship, relationshipAssignment); + var rightValue = relationship.GetValue(resource); + await AssignValueToRelationship(relationship, resource, rightValue); } _dbContext.Set().Add(resource); @@ -152,9 +152,9 @@ public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection rightResourcesInToManyRelationship) { - trackedRelationshipAssignment = null; - } - else if (relationshipAssignment is IIdentifiable hasOneValue) + return EnsureToManyRelationshipValueToAssignIsTracked(rightResourcesInToManyRelationship, relationshipPropertyType); + } + + if (valueToAssign is IIdentifiable rightResourceInToOneRelationship) { - trackedRelationshipAssignment = GetTrackedOrAttach(hasOneValue); + return _dbContext.GetTrackedOrAttach(rightResourceInToOneRelationship); } - else + + return null; + } + + private object EnsureToManyRelationshipValueToAssignIsTracked(ICollection rightResources, Type rightCollectionType) + { + var rightResourcesTracked = new object[rightResources.Count]; + + int index = 0; + foreach (var rightResource in rightResources) { - var hasManyValue = (IReadOnlyCollection)relationshipAssignment; - var trackedHasManyValues = new object[hasManyValue.Count]; + var trackedIdentifiable = _dbContext.GetTrackedOrAttach(rightResource); - for (int i = 0; i < hasManyValue.Count; i++) - { - var trackedHasManyValue = GetTrackedOrAttach(hasManyValue.ElementAt(i)); - - // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. - var conversionTarget = trackedHasManyValue.GetType(); - trackedHasManyValues[i] = Convert.ChangeType(trackedHasManyValue, conversionTarget); - } + // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. + var identifiableRuntimeType = trackedIdentifiable.GetType(); + rightResourcesTracked[index] = Convert.ChangeType(trackedIdentifiable, identifiableRuntimeType); - trackedRelationshipAssignment = TypeHelper.CopyToTypedCollection(trackedHasManyValues, relationshipType); + index++; } - - return trackedRelationshipAssignment; + + return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } private PropertyInfo[] GetForeignKey(RelationshipAttribute relationship) @@ -463,18 +466,6 @@ private PropertyInfo[] GetForeignKey(RelationshipAttribute relationship) return Array.Empty(); } - private IIdentifiable GetTrackedOrAttach(IIdentifiable resource) - { - var trackedResource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); - if (trackedResource == null) - { - _dbContext.Entry(resource).State = EntityState.Unchanged; - trackedResource = resource; - } - - return trackedResource; - } - private async Task SaveChangesAsync() { try From 3106f14e13a0c8e8992bff00ed14bed6697a37af Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Oct 2020 16:31:58 +0200 Subject: [PATCH 057/240] fixed test for composite foreign keys --- ...roductsController.cs => CarsController.cs} | 6 +- .../Data/AppDbContext.cs | 15 ++- .../JsonApiDotNetCoreExample/Models/Car.cs | 44 +++++++ .../Models/Category.cs | 41 ------ .../JsonApiDotNetCoreExample/Models/Engine.cs | 14 ++ .../Models/Product.cs | 18 --- .../Repositories/CarRepository.cs | 124 ++++++++++++++++++ .../EntityFrameworkCoreRepository.cs | 9 +- .../Acceptance/Spec/UpdatingDataTests.cs | 42 +++--- 9 files changed, 219 insertions(+), 94 deletions(-) rename src/Examples/JsonApiDotNetCoreExample/Controllers/{ProductsController.cs => CarsController.cs} (71%) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Car.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Category.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Product.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/CarsController.cs similarity index 71% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs rename to src/Examples/JsonApiDotNetCoreExample/Controllers/CarsController.cs index d17bb7b00a..90ec9ae3cf 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ProductsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/CarsController.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class ProductsController : JsonApiController + public sealed class CarsController : JsonApiController { - public ProductsController( + public CarsController( IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 94fba95824..aa42c383ac 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -22,7 +22,8 @@ public sealed class AppDbContext : DbContext public DbSet ArticleTags { get; set; } public DbSet Tags { get; set; } public DbSet Blogs { get; set; } - public DbSet Products { get; set; } + public DbSet Cars { get; set; } + public DbSet Engines { get; set; } public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) { @@ -94,13 +95,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(p => p.OneToOnePerson) .HasForeignKey(p => p.OneToOnePersonId); - modelBuilder.Entity() - .HasKey(c => new { c.CountryId, c.ShopId }); + modelBuilder.Entity() + .HasKey(c => new { c.RegionCode, c.LicensePlate }); - modelBuilder.Entity() - .HasOne(p => p.Category) - .WithMany(c => c.Products) - .HasForeignKey(p => new { p.CountryId, p.ShopId }); + modelBuilder.Entity() + .HasOne(e => e.Car) + .WithOne(c => c.Engine) + .HasForeignKey(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs new file mode 100644 index 0000000000..720acb2c2d --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs @@ -0,0 +1,44 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Car : Identifiable + { + [NotMapped] + public override string Id + { + get => base.Id; + set => base.Id = value; + } + + protected override string GetStringId(string value) + { + return $"{RegionCode}:{LicensePlate}"; + } + + protected override string GetTypedId(string value) + { + var elements = value.Split(':'); + if (elements.Length == 2) + { + RegionCode = elements[0]; + LicensePlate = elements[1]; + return value; + } + + throw new InvalidOperationException($"Failed to convert ID '{value}'."); + } + + [Attr] + public string LicensePlate { get; set; } + + [Attr] + public string RegionCode { get; set; } + + [HasOne] + public Engine Engine { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs deleted file mode 100644 index e0382c193c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Category.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class Category : Identifiable - { - public override string Id - { - get => $"{CountryId}-{ShopId}"; - set - { - var elements = value.Split('-'); - if (elements.Length == 2) - { - if (int.TryParse(elements[0], out var countryId)) - { - CountryId = countryId; - ShopId = elements[1]; - return; - } - } - - throw new InvalidOperationException($"Failed to convert ID '{value}'."); - } - } - - [Attr] - public string Name { get; set; } - - [HasMany] - public ICollection Products { get; set; } - - public int? CountryId { get; set; } - - public string ShopId { get; set; } - - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs new file mode 100644 index 0000000000..a9319dee59 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Engine : Identifiable + { + [Attr] + public string SerialCode { get; set; } + + [HasOne] + public Car Car { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Product.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Product.cs deleted file mode 100644 index 91c502ee97..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Product.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class Product : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasOne] - public Category Category { get; set; } - - public int? CountryId { get; set; } - - public string ShopId { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs new file mode 100644 index 0000000000..4bb893ed32 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Repositories +{ + public sealed class CarRepository : EntityFrameworkCoreRepository + { + private readonly IResourceGraph _resourceGraph; + + public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + _resourceGraph = resourceGraph; + } + + protected override IQueryable ApplyQueryLayer(QueryLayer layer) + { + RecursiveRewriteFilterInLayer(layer); + + return base.ApplyQueryLayer(layer); + } + + private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) + { + if (queryLayer.Filter != null) + { + var writer = new CarFilterRewriter(_resourceGraph); + queryLayer.Filter = (FilterExpression) writer.Visit(queryLayer.Filter, null); + } + + if (queryLayer.Projection != null) + { + foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) + { + RecursiveRewriteFilterInLayer(nextLayer); + } + } + } + + private sealed class CarFilterRewriter : QueryExpressionRewriter + { + private readonly AttrAttribute _regionCodeAttribute; + private readonly AttrAttribute _licensePlateAttribute; + + public CarFilterRewriter(IResourceContextProvider resourceContextProvider) + { + var carResourceContext = resourceContextProvider.GetResourceContext(); + + _regionCodeAttribute = + carResourceContext.Attributes.Single(attribute => + attribute.Property.Name == nameof(Car.RegionCode)); + + _licensePlateAttribute = + carResourceContext.Attributes.Single(attribute => + attribute.Property.Name == nameof(Car.LicensePlate)); + } + + public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) + { + if (expression.Left is ResourceFieldChainExpression leftChain && + expression.Right is LiteralConstantExpression rightConstant) + { + PropertyInfo leftProperty = leftChain.Fields.Last().Property; + if (IsCarId(leftProperty)) + { + if (expression.Operator != ComparisonOperator.Equals) + { + throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); + } + + return RewriteEqualityComparisonForCarStringId(rightConstant.Value); + } + } + + return base.VisitComparison(expression, argument); + } + + private static bool IsCarId(PropertyInfo property) + { + return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); + } + + private QueryExpression RewriteEqualityComparisonForCarStringId(string carStringId) + { + var tempCar = new Car + { + StringId = carStringId + }; + + return CreateEqualityComparisonOnRegionCodeLicensePlate(tempCar.RegionCode, tempCar.LicensePlate); + } + + private QueryExpression CreateEqualityComparisonOnRegionCodeLicensePlate(string regionCodeValue, + string licensePlateValue) + { + var regionCodeComparison = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(_regionCodeAttribute), + new LiteralConstantExpression(regionCodeValue)); + + var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(_licensePlateAttribute), + new LiteralConstantExpression(licensePlateValue)); + + return new LogicalExpression(LogicalOperator.And, new[] + { + regionCodeComparison, + licensePlateComparison + }); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 724678ac4e..de2db17fe3 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -306,8 +306,9 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute { var foreignKeyProperties = GetForeignKey(hasOneRelationship); if (foreignKeyProperties.Length != 1) - { // If the primary resource is the dependent side of a to-one relationship, there can be no FK - // violations resulting from a the implicit removal. + { + // If the primary resource is the dependent side of a to-one relationship, there can be no FK + // violations resulting from the implicit removal. navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); } } @@ -416,7 +417,7 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) { - if (valueToAssign is ICollection rightResourcesInToManyRelationship) + if (valueToAssign is IReadOnlyCollection rightResourcesInToManyRelationship) { return EnsureToManyRelationshipValueToAssignIsTracked(rightResourcesInToManyRelationship, relationshipPropertyType); } @@ -429,7 +430,7 @@ private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Ty return null; } - private object EnsureToManyRelationshipValueToAssignIsTracked(ICollection rightResources, Type rightCollectionType) + private object EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollection rightResources, Type rightCollectionType) { var rightResourcesTracked = new object[rightResources.Count]; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 4df27b6b3c..733bd8a15b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -481,27 +481,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => updated.Owner.Id.Should().Be(person.Id); }); } - + [Fact] public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key() { // Arrange - var product = new Product - { - Name = "Croissants" - }; - var category = new Category + var car = new Car { - Id = "4234-FRENCHSPECIALTIES", - Name = "French Specialties" + RegionCode = "NL", + LicensePlate = "AA-BB-11", + Engine = new Engine + { + SerialCode = "1234567890" + } }; - + await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTablesAsync(); - dbContext.AddRange(product, category); - await dbContext.SaveChangesAsync(); - product.Category = category; + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); await dbContext.SaveChangesAsync(); }); @@ -509,11 +507,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "products", - id = product.Id, + type = "cars", + id = car.StringId, relationships = new Dictionary { - ["category"] = new + ["engine"] = new { data = (object)null } @@ -521,7 +519,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/products/" + product.Id; + var route = "/api/v1/cars/" + car.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -529,13 +527,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.Should().BeNull(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertProduct = await dbContext.Products - .Include(m => m.Category) - .SingleAsync(h => h.Id == product.Id); + var engineInDatabase = await dbContext.Engines + .Include(e => e.Car) + .SingleAsync(e => e.Id == car.Engine.Id); - assertProduct.Category.Should().BeNull(); + engineInDatabase.Car.Should().BeNull(); }); } } From 25a1604b78be26547278b01dc56e97d1b1b751c9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Oct 2020 22:53:52 +0200 Subject: [PATCH 058/240] Temporary snapshot for work in progress... --- .../Controllers/EnginesController.cs | 18 ++ .../Data/AppDbContext.cs | 4 +- .../JsonApiDotNetCoreExample/Models/Car.cs | 13 +- .../Models/TodoItem.cs | 2 +- .../Repositories/CarRepository.cs | 18 +- .../ApplicationBuilderExtensions.cs | 2 +- ...ips.cs => IInverseRelationshipResolver.cs} | 9 +- ...hips.cs => InverseRelationshipResolver.cs} | 8 +- .../JsonApiApplicationBuilder.cs | 2 +- .../Configuration/ResourceGraph.cs | 4 +- .../Hooks/Internal/ResourceHookExecutor.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 214 +++++++++++------- .../Annotations/RelationshipAttribute.cs | 20 +- .../Acceptance/Spec/CreatingDataTests.cs | 5 +- .../Acceptance/Spec/UpdatingDataTests.cs | 157 ++++++++++++- .../Spec/UpdatingRelationshipsTests.cs | 3 +- .../Acceptance/TodoItemControllerTests.cs | 2 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 2 +- 18 files changed, 352 insertions(+), 133 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs rename src/JsonApiDotNetCore/Configuration/{IInverseRelationships.cs => IInverseRelationshipResolver.cs} (79%) rename src/JsonApiDotNetCore/Configuration/{InverseRelationships.cs => InverseRelationshipResolver.cs} (80%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs new file mode 100644 index 0000000000..73499102de --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class EnginesController : JsonApiController + { + public EnginesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index aa42c383ac..9e3c6b256a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -47,7 +47,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(t => t.Owner) .WithMany(p => p.TodoItems) - .HasForeignKey(t => t.OwnerId); + /*.HasForeignKey(t => t.OwnerId)*/; modelBuilder.Entity() .HasKey(bc => new { bc.ArticleId, bc.TagId }); @@ -96,7 +96,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(p => p.OneToOnePersonId); modelBuilder.Entity() - .HasKey(c => new { c.RegionCode, c.LicensePlate }); + .HasKey(c => new { c.RegionId, c.LicensePlate }); modelBuilder.Entity() .HasOne(e => e.Car) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs index 720acb2c2d..f557caf89a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs @@ -16,7 +16,7 @@ public override string Id protected override string GetStringId(string value) { - return $"{RegionCode}:{LicensePlate}"; + return $"{RegionId}:{LicensePlate}"; } protected override string GetTypedId(string value) @@ -24,9 +24,12 @@ protected override string GetTypedId(string value) var elements = value.Split(':'); if (elements.Length == 2) { - RegionCode = elements[0]; - LicensePlate = elements[1]; - return value; + if (int.TryParse(elements[0], out int regionId)) + { + RegionId = regionId; + LicensePlate = elements[1]; + return value; + } } throw new InvalidOperationException($"Failed to convert ID '{value}'."); @@ -36,7 +39,7 @@ protected override string GetTypedId(string value) public string LicensePlate { get; set; } [Attr] - public string RegionCode { get; set; } + public long RegionId { get; set; } [HasOne] public Engine Engine { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 782b2521be..7e50b2ced0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -45,7 +45,7 @@ public string AlwaysChangingValue [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public DateTimeOffset? OffsetDate { get; set; } - public int? OwnerId { get; set; } + //public int? OwnerId { get; set; } public int? AssigneeId { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs index 4bb893ed32..e9c56e8b88 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs @@ -51,16 +51,16 @@ private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) private sealed class CarFilterRewriter : QueryExpressionRewriter { - private readonly AttrAttribute _regionCodeAttribute; + private readonly AttrAttribute _regionIdAttribute; private readonly AttrAttribute _licensePlateAttribute; public CarFilterRewriter(IResourceContextProvider resourceContextProvider) { var carResourceContext = resourceContextProvider.GetResourceContext(); - _regionCodeAttribute = + _regionIdAttribute = carResourceContext.Attributes.Single(attribute => - attribute.Property.Name == nameof(Car.RegionCode)); + attribute.Property.Name == nameof(Car.RegionId)); _licensePlateAttribute = carResourceContext.Attributes.Single(attribute => @@ -99,15 +99,15 @@ private QueryExpression RewriteEqualityComparisonForCarStringId(string carString StringId = carStringId }; - return CreateEqualityComparisonOnRegionCodeLicensePlate(tempCar.RegionCode, tempCar.LicensePlate); + return CreateEqualityComparisonOnRegionIdLicensePlate(tempCar.RegionId, tempCar.LicensePlate); } - private QueryExpression CreateEqualityComparisonOnRegionCodeLicensePlate(string regionCodeValue, + private QueryExpression CreateEqualityComparisonOnRegionIdLicensePlate(long regionIdValue, string licensePlateValue) { - var regionCodeComparison = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(_regionCodeAttribute), - new LiteralConstantExpression(regionCodeValue)); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(_regionIdAttribute), + new LiteralConstantExpression(regionIdValue.ToString())); var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(_licensePlateAttribute), @@ -115,7 +115,7 @@ private QueryExpression CreateEqualityComparisonOnRegionCodeLicensePlate(string return new LogicalExpression(LogicalOperator.And, new[] { - regionCodeComparison, + regionIdComparison, licensePlateComparison }); } diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 0d4d20f59c..99b671e411 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -25,7 +25,7 @@ public static void UseJsonApi(this IApplicationBuilder builder) if (builder == null) throw new ArgumentNullException(nameof(builder)); using var scope = builder.ApplicationServices.GetRequiredService().CreateScope(); - var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); + var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); inverseRelationshipResolver.Resolve(); var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); diff --git a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs similarity index 79% rename from src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs rename to src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs index b15afea2ce..589cae301d 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs @@ -3,20 +3,19 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Responsible for populating the property. + /// Responsible for populating the property. /// /// This service is instantiated in the configure phase of the application. /// /// When using a data access layer different from EF Core, and when using ResourceHooks /// that depend on the inverse navigation property (BeforeImplicitUpdateRelationship), - /// you will need to override this service, or pass along the inverseNavigationProperty in + /// you will need to override this service, or pass along the InverseRelationshipPropertyName in /// the RelationshipAttribute. /// - public interface IInverseRelationships + public interface IInverseRelationshipResolver { /// - /// This method is called upon startup by JsonApiDotNetCore. It should - /// deal with resolving the inverse relationships. + /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse relationships. /// void Resolve(); } diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs similarity index 80% rename from src/JsonApiDotNetCore/Configuration/InverseRelationships.cs rename to src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs index f8164b490e..86ec4932fc 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCore.Configuration { /// - public class InverseRelationships : IInverseRelationships + public class InverseRelationshipResolver : IInverseRelationshipResolver { private readonly IResourceContextProvider _resourceContextProvider; private readonly IEnumerable _dbContextResolvers; - public InverseRelationships(IResourceContextProvider resourceContextProvider, + public InverseRelationshipResolver(IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) { _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); @@ -41,8 +41,10 @@ private void Resolve(DbContext dbContext) { if (!(relationship is HasManyThroughAttribute)) { + // TODO: Replace Relationship.InverseRelationshipPropertyName (string) with RelationShip.InverseRelationship object that we assign here. + INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.FindInverse(); - relationship.InverseNavigation = inverseNavigation?.Name; + relationship.InverseRelationshipPropertyName = inverseNavigation?.Name; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index ff5d1c27c0..35768f220a 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -148,7 +148,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(); _services.AddScoped(); - _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddMiddlewareLayer() diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index a1acc938d8..89d0f08b18 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -90,10 +90,10 @@ public RelationshipAttribute GetInverseRelationship(RelationshipAttribute relati { if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationship.InverseNavigation == null) return null; + if (relationship.InverseRelationshipPropertyName == null) return null; return GetResourceContext(relationship.RightType) .Relationships - .SingleOrDefault(r => r.Property.Name == relationship.InverseNavigation); + .SingleOrDefault(r => r.Property.Name == relationship.InverseRelationshipPropertyName); } private IReadOnlyCollection Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index 1c0721a256..3974d75021 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -337,7 +337,7 @@ private Dictionary ReplaceKeysWithInverseRel // that the inverse attribute was also set (Owner has one Article: HasOneAttr:article). // If it isn't, JADNC currently knows nothing about this relationship pointing back, and it // currently cannot fire hooks for resources resolved through inverse relationships. - var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseNavigation != null); + var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseRelationshipPropertyName != null); return inversableRelationshipAttributes.ToDictionary(kvp => _resourceGraph.GetInverseRelationship(kvp.Key), kvp => kvp.Value); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index de2db17fe3..a21abf6cf2 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,7 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -13,6 +12,7 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -164,9 +164,20 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); var relationship = _targetedFields.Relationships.Single(); - var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreateInstanceWithAssignedId(id)); - - await LoadRelationship(primaryResource, relationship); + + TResource primaryResource; + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtSideOfHasOneRelationship(hasOneRelationship)) + { + primaryResource = await _dbContext.Set() + .Include(relationship.Property.Name) + .Where(r => r.Id.Equals(id)) + .FirstAsync(); + } + else + { + primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreateInstanceWithAssignedId(id)); + await LoadRelationship(primaryResource, relationship); + } await AssignValueToRelationship(relationship, primaryResource, secondaryResourceIds); @@ -187,14 +198,29 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (var relationship in _targetedFields.Relationships) { - // A database entity might not be tracked if it was retrieved through projection. - resourceFromDatabase = (TResource) _dbContext.GetTrackedOrAttach(resourceFromDatabase); + if (relationship is HasOneAttribute hasOneRelationship && + HasForeignKeyAtSideOfHasOneRelationship(hasOneRelationship)) + { + FlushFromCache(resourceFromDatabase); - // Ensures complete replacements of relationships. - await LoadRelationship(resourceFromDatabase, relationship); + resourceFromDatabase = await _dbContext.Set() + .Include(relationship.Property.Name) + .Where(r => r.Id.Equals(resourceFromRequest.Id)) + .FirstAsync(); + } + else + { + // A database entity might not be tracked if it was retrieved through projection. + resourceFromDatabase = (TResource) _dbContext.GetTrackedOrAttach(resourceFromDatabase); + + // Ensures complete replacements of relationships. + await LoadRelationship(resourceFromDatabase, relationship); + } var relationshipAssignment = relationship.GetValue(resourceFromRequest); await AssignValueToRelationship(relationship, resourceFromDatabase, relationshipAssignment); + + //_dbContext.Entry(resourceFromDatabase).State = EntityState.Modified; } await SaveChangesAsync(); @@ -252,23 +278,24 @@ private void DetachRelationships(TResource resource) { foreach (var relationship in _targetedFields.Relationships) { - var value = relationship.GetValue(resource); - if (value == null) - continue; + var rightValue = relationship.GetValue(resource); - if (value is IEnumerable collection) + if (rightValue is IEnumerable rightResources) { - foreach (IIdentifiable single in collection) - _dbContext.Entry(single).State = EntityState.Detached; - // detaching has many relationships is not sufficient to + foreach (var rightResource in rightResources) + { + _dbContext.Entry(rightResource).State = EntityState.Detached; + } + + // Detaching to-many relationships is not sufficient to // trigger a full reload of relationships: the navigation // property actually needs to be nulled out, otherwise - // EF will still add duplicate instances to the collection + // EF Core will still add duplicate instances to the collection. relationship.SetValue(resource, null, _resourceFactory); } - else + else if (rightValue != null) { - _dbContext.Entry(value).State = EntityState.Detached; + _dbContext.Entry(rightValue).State = EntityState.Detached; } } } @@ -304,13 +331,15 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute } else if (relationship is HasOneAttribute hasOneRelationship) { - var foreignKeyProperties = GetForeignKey(hasOneRelationship); - if (foreignKeyProperties.Length != 1) + navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); + + /*var foreignKey = GetForeignKeyAtSideOfHasOneRelationship(hasOneRelationship); + if (foreignKey == null || foreignKey.Properties.Count != 1) { // If the primary resource is the dependent side of a to-one relationship, there can be no FK // violations resulting from the implicit removal. navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); - } + }*/ } if (navigationEntry != null) @@ -322,97 +351,111 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute /// /// Loads the inverse relationships to prevent foreign key constraints from being violated /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// + /// /// Consider the following example: - /// person.todoItems = [t1,t2] is updated to [t3, t4]. If t3, and/or t4 was - /// already related to a other person, and these persons are NOT loaded into the - /// DbContext, then the query may cause a foreign key constraint. Loading - /// these "inverse relationships" into the DB context ensures EF core to take - /// this into account. - /// + /// person.todoItems = [t1, t2] is updated to [t3, t4]. If t3 and/or t4 was + /// already related to another person, and these persons are NOT loaded into the + /// DbContext, then the query may fail with a foreign key constraint violation. Loading + /// these "inverse relationships" into the DbContext ensures EF Core to take this into account. + /// /// - private async Task LoadInverseRelationships(object relationshipAssignment, RelationshipAttribute relationship) + private async Task LoadInverseRelationshipsInChangeTracker(RelationshipAttribute relationship, object resource) { - var inverseNavigation = relationship.InverseNavigation; - - if (inverseNavigation != null) + if (relationship.InverseRelationshipPropertyName != null) { if (relationship is HasOneAttribute hasOneRelationship) { - var entityEntry = _dbContext.Entry(relationshipAssignment); + var entityEntry = _dbContext.Entry(resource); if (IsOneToOne(hasOneRelationship)) { - await entityEntry.Reference(inverseNavigation).LoadAsync(); + await entityEntry.Reference(relationship.InverseRelationshipPropertyName).LoadAsync(); } else { - await entityEntry.Collection(inverseNavigation).LoadAsync(); + await entityEntry.Collection(relationship.InverseRelationshipPropertyName).LoadAsync(); } } - else if (!(relationship is HasManyThroughAttribute)) + else if (relationship is HasManyThroughAttribute) + { + // TODO: What should happen in this case? + } + else { - var loadTasks = ((IReadOnlyCollection)relationshipAssignment) - .Select(resource => _dbContext.Entry(resource).Reference(inverseNavigation).LoadAsync()); - await Task.WhenAll(loadTasks); + var resources = (IEnumerable)resource; + + foreach (var nextResource in resources) + { + var nextEntityEntry = _dbContext.Entry(nextResource); + await nextEntityEntry.Reference(relationship.InverseRelationshipPropertyName).LoadAsync(); + } } } } - private bool IsOneToOne(HasOneAttribute relationship) + private bool IsOneToOne(HasOneAttribute hasOneRelationship) { - var relationshipType = relationship.RightType; - var inverseNavigation = relationship.InverseNavigation; - bool inversePropertyIsEnumerable; - - var inverseRelationship = _resourceGraph.GetRelationships(relationshipType).FirstOrDefault(r => r.Property.Name == inverseNavigation); - if (inverseRelationship == null) - { - // inverseRelationship is null when there is no RelationshipAttribute on the inverse navigation property. - // In this case we reflect on the type to figure out what kind of relationship is pointing back. - var inverseProperty = relationshipType.GetProperty(inverseNavigation).PropertyType; - inversePropertyIsEnumerable = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); - } - else + var inverseRelationship = _resourceGraph + .GetRelationships(hasOneRelationship.RightType) + .FirstOrDefault(r => r.Property.Name == hasOneRelationship.InverseRelationshipPropertyName); + + if (inverseRelationship != null) { - inversePropertyIsEnumerable = !(inverseRelationship is HasOneAttribute); + return inverseRelationship is HasOneAttribute; } - - return !inversePropertyIsEnumerable; + + // inverseRelationship is null when there is no RelationshipAttribute on the inverse navigation property. + // In this case we reflect on the type to figure out what kind of relationship is pointing back. + + // TODO: If there is no InverseRelationshipPropertyName, I don't think the next line ever matches anything. + // On the other hand, if there is one, then we would have found it in the lines above. + var inverseProperty = hasOneRelationship.RightType.GetProperty(hasOneRelationship.InverseRelationshipPropertyName).PropertyType; + + var isCollection = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); + return !isCollection; } private async Task AssignValueToRelationship(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { // Ensures the new relationship assignment will not result in entities being tracked more than once. - object trackedRelationshipAssignment = null; + object trackedValueToAssign = null; if (valueToAssign != null) { - trackedRelationshipAssignment = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); // Ensures successful handling of implicit removals of relationships. - await LoadInverseRelationships(trackedRelationshipAssignment, relationship); + await LoadInverseRelationshipsInChangeTracker(relationship, trackedValueToAssign); } - if (relationship is HasOneAttribute) + if (relationship is HasOneAttribute hasOneRelationship) { - object secondaryResourceId = null; - - if (trackedRelationshipAssignment is IIdentifiable secondaryResource) + var rightResourceId = trackedValueToAssign is IIdentifiable rightResource + ? rightResource.GetTypedId() + : null; + + // https://docs.microsoft.com/en-us/ef/core/saving/related-data + /* + var foreignKey = GetForeignKeyAtSideOfHasOneRelationship(hasOneRelationship); + if (foreignKey != null) { - secondaryResourceId = secondaryResource.GetTypedId(); - } - - var foreignKeyProperties = GetForeignKey(relationship); - if (foreignKeyProperties.Length == 1) - { - foreignKeyProperties.First().SetValue(leftResource, secondaryResourceId); - _dbContext.Entry(leftResource).State = EntityState.Modified; - } + foreach (var foreignKeyProperty in foreignKey.Properties) + { + if (foreignKeyProperty.IsShadowProperty()) + { + _dbContext.Entry(leftResource).Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; + } + else + { + foreignKeyProperty.PropertyInfo.SetValue(leftResource, rightResourceId); + _dbContext.Entry(leftResource).State = EntityState.Modified; + } + } + }*/ } - relationship.SetValue(leftResource, trackedRelationshipAssignment, _resourceFactory); + relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) @@ -449,22 +492,21 @@ private object EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollectio return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private PropertyInfo[] GetForeignKey(RelationshipAttribute relationship) + private bool HasForeignKeyAtSideOfHasOneRelationship(HasOneAttribute relationship) { - if (relationship is HasOneAttribute) - { - var entityMetadata = _dbContext.Model.FindEntityType(typeof(TResource)); - var foreignKeyMetadata = entityMetadata.FindNavigation(relationship.Property.Name).ForeignKey; - - var declaringEntityType = foreignKeyMetadata.DeclaringEntityType.ClrType; + var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + var navigation = entityType.FindNavigation(relationship.Property.Name); - if (declaringEntityType == typeof(TResource)) - { - return foreignKeyMetadata.Properties.Select(p => p.PropertyInfo).Where(pi => pi != null).ToArray(); - } - } + return navigation.ForeignKey.DeclaringEntityType.ClrType == typeof(TResource); + } + + private IForeignKey GetForeignKeyAtSideOfHasOneRelationship(HasOneAttribute relationship) + { + var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + var navigation = entityType.FindNavigation(relationship.Property.Name); - return Array.Empty(); + var isForeignKeyAtRelationshipSide = navigation.ForeignKey.DeclaringEntityType.ClrType == typeof(TResource); + return isForeignKeyAtRelationshipSide ? navigation.ForeignKey : null; } private async Task SaveChangesAsync() diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index 11dffec12d..0178e32b98 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -11,7 +11,25 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute { private LinkTypes _links; - public string InverseNavigation { get; set; } + /// + /// The property name of the inverse relationship, if any. + /// + /// + /// Articles { get; set; } + /// } + /// ]]> + /// + public string InverseRelationshipPropertyName { get; internal set; } /// /// The internal navigation property path to the related resource. diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index ae15051ab4..ba787d5916 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -179,7 +179,7 @@ public async Task CreateWithRelationship_HasOne_IsCreated() var todoItemResult = GetDbContext().TodoItems.AsNoTracking() .Include(c => c.Owner) .SingleOrDefault(c => c.Id == responseItem.Id); - Assert.Equal(owner.Id, todoItemResult.OwnerId); + Assert.Equal(owner.Id, todoItemResult.Owner.Id); } [Fact] @@ -244,9 +244,10 @@ public async Task CreateWithRelationship_HasOneFromIndependentSide_IsCreated() _dbContext.People.Add(person); await _dbContext.SaveChangesAsync(); var personRole = new PersonRole { Person = person }; + var requestBody = serializer.Serialize(personRole); // Act - var (body, response) = await Post("/api/v1/personRoles", serializer.Serialize(personRole)); + var (body, response) = await Post("/api/v1/personRoles", requestBody); // Assert AssertEqualStatusCode(HttpStatusCode.Created, response); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 733bd8a15b..2b441b9fef 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -483,23 +483,97 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key() + public async Task Can_Get_By_Composite_Key() { // Arrange var car = new Car { - RegionCode = "NL", + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/cars/" + car.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(car.StringId); + } + + // TODO: Remove temporary code for experiments. + [Fact] + public async Task Demo_Composite_Key_Navigation_Assignment() + { + // Arrange + var engine = new Engine + { + SerialCode = "1234567890" + }; + + var car = new Car + { + RegionId = 123, LicensePlate = "AA-BB-11", - Engine = new Engine + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + dbContext.AddRange(engine, car); + await dbContext.SaveChangesAsync(); + }); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + engine = await dbContext.Engines.FirstAsync(e => e.Id.Equals(engine.Id)); + car = await dbContext.Cars.FirstAsync(c => c.RegionId == car.RegionId && c.LicensePlate == car.LicensePlate); + + // Act + engine.Car = car; + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var engineInDatabase = await dbContext.Engines + .Include(e => e.Car) + .SingleAsync(e => e.Id == engine.Id); + + engineInDatabase.Car.Should().NotBeNull(); + }); + } + + [Fact] + public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key() + { + // Arrange + var engine = new Engine + { + SerialCode = "1234567890", + Car = new Car { - SerialCode = "1234567890" + RegionId = 123, + LicensePlate = "AA-BB-11", } }; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); + dbContext.Engines.Add(engine); await dbContext.SaveChangesAsync(); }); @@ -507,11 +581,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "cars", - id = car.StringId, + type = "engines", + id = engine.StringId, relationships = new Dictionary { - ["engine"] = new + ["car"] = new { data = (object)null } @@ -519,7 +593,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/cars/" + car.StringId; + var route = "/api/v1/engines/" + engine.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -533,10 +607,73 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines .Include(e => e.Car) - .SingleAsync(e => e.Id == car.Engine.Id); + .SingleAsync(e => e.Id == engine.Id); engineInDatabase.Car.Should().BeNull(); }); } + + [Fact] + public async Task Can_Assign_Relationship_Of_Resource_With_Composite_Foreign_Key() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + var engine = new Engine + { + SerialCode = "1234567890" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(car, engine); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "engines", + id = engine.StringId, + relationships = new Dictionary + { + ["car"] = new + { + data = new + { + type = "cars", + id = car.StringId + } + } + } + } + }; + + var route = "/api/v1/engines/" + engine.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var engineInDatabase = await dbContext.Engines + .Include(e => e.Car) + .SingleAsync(e => e.Id == engine.Id); + + engineInDatabase.Car.Should().NotBeNull(); + engineInDatabase.Car.Id.Should().Be(car.StringId); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index c4c0cadd20..5c42ce8f9e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -820,9 +820,8 @@ public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpoint() { // Arrange - var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index 9a75fcbb82..efc4d7538b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -206,7 +206,7 @@ public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() // Assert -- database var todoItemResult = await _context.TodoItems.SingleAsync(t => t.Id == resultId); - Assert.Equal(person1.Id, todoItemResult.OwnerId); + Assert.Equal(person1.Id, todoItemResult.Owner.Id); Assert.Equal(person2.Id, todoItemResult.AssigneeId); } diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 03dc011bf0..8383913706 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -386,7 +386,7 @@ private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) private void ResolveInverseRelationships(AppDbContext context) { var dbContextResolvers = new[] {new DbContextResolver(context)}; - var inverseRelationships = new InverseRelationships(_resourceGraph, dbContextResolvers); + var inverseRelationships = new InverseRelationshipResolver(_resourceGraph, dbContextResolvers); inverseRelationships.Resolve(); } From a599f6083177f7af43208d73c7a24bb1c794bbb1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 01:21:43 +0200 Subject: [PATCH 059/240] Rewrite of UpdatingRelationshipsTests to new syntax --- .../Errors/RelationshipNotFoundException.cs | 4 +- .../ResourceDefinitionTests.cs | 16 +- .../Spec/FetchingRelationshipsTests.cs | 4 +- .../Spec/ResourceTypeMismatchTests.cs | 2 +- .../Spec/UpdatingRelationshipsTests.cs | 1185 +++++++---------- .../IntegrationTestContext.cs | 4 +- .../ResourceInheritance/InheritanceTests.cs | 24 +- 7 files changed, 539 insertions(+), 700 deletions(-) diff --git a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs index a56091151a..313222f4a2 100644 --- a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs @@ -8,10 +8,10 @@ namespace JsonApiDotNetCore.Errors /// public sealed class RelationshipNotFoundException : JsonApiException { - public RelationshipNotFoundException(string relationshipName, string containingResourceName) : base(new Error(HttpStatusCode.NotFound) + public RelationshipNotFoundException(string relationshipName, string resourceType) : base(new Error(HttpStatusCode.NotFound) { Title = "The requested relationship does not exist.", - Detail = $"The resource '{containingResourceName}' does not contain a relationship named '{relationshipName}'." + Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." }) { } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 8ae419de1e..d07e700fd3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -284,7 +284,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() { { "passport", new { - data = new { type = "passports", id = $"{lockedPerson.Passport.StringId}" } + data = new { type = "passports", id = lockedPerson.Passport.StringId } } } } @@ -335,7 +335,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() { { "passport", new { - data = new { type = "passports", id = $"{newPassport.StringId}" } + data = new { type = "passports", id = newPassport.StringId } } } } @@ -464,10 +464,10 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() { { "stakeHolders", new { - data = new object[] + data = new[] { - new { type = "people", id = $"{persons[0].Id}" }, - new { type = "people", id = $"{persons[1].Id}" } + new { type = "people", id = persons[0].StringId }, + new { type = "people", id = persons[1].StringId } } } @@ -521,10 +521,10 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() { { "stakeHolders", new { - data = new object[] + data = new[] { - new { type = "people", id = $"{persons[0].Id}" }, - new { type = "people", id = $"{persons[1].Id}" } + new { type = "people", id = persons[0].StringId }, + new { type = "people", id = persons[1].StringId } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index bf64c92d36..a5f4012f56 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -334,7 +334,7 @@ public async Task When_getting_unknown_related_resource_it_should_fail() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } [Fact] @@ -364,7 +364,7 @@ public async Task When_getting_unknown_relationship_for_resource_it_should_fail( Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs index 141c4ab96c..99f6be0663 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -87,7 +87,7 @@ public async Task Patching_Through_Relationship_Link_With_Multiple_Resources_Typ // Arrange string content = JsonConvert.SerializeObject(new { - data = new object[] + data = new[] { new { type = "todoItems", id = 1 }, new { type = "articles", id = 2 }, diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 5c42ce8f9e..cc462ac882 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -2,47 +2,39 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; +using FluentAssertions; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Internal; -using Newtonsoft.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { - [Collection("WebHostCollection")] - public sealed class UpdatingRelationshipsTests + public sealed class UpdatingRelationshipsTests : IClassFixture> { - private readonly TestFixture _fixture; - private AppDbContext _context; - private readonly Faker _personFaker; - private readonly Faker _todoItemFaker; + private readonly IntegrationTestContext _testContext; - public UpdatingRelationshipsTests(TestFixture fixture) + private readonly Faker _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()) + .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + + private readonly Faker _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); + + public UpdatingRelationshipsTests(IntegrationTestContext testContext) { - _fixture = fixture; - _context = fixture.GetRequiredService(); - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); - - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + _testContext = testContext; } [Fact] @@ -50,58 +42,53 @@ public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() { // Arrange var todoItem = _todoItemFaker.Generate(); - var strayTodoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - _context.TodoItems.Add(strayTodoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); + var otherTodoItem = _todoItemFaker.Generate(); - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(todoItem, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - // Act - var content = new + var requestBody = new { data = new { type = "todoItems", - id = todoItem.Id, + id = todoItem.StringId, relationships = new Dictionary { + ["childrenTodos"] = new { - "childrenTodos", new + data = new[] { - data = new object[] - { - new {type = "todoItems", id = $"{todoItem.Id}"}, - new {type = "todoItems", id = $"{strayTodoItem.Id}"} - } + new {type = "todoItems", id = todoItem.StringId}, + new {type = "todoItems", id = otherTodoItem.StringId} } } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - await client.SendAsync(request); - _context = _fixture.GetRequiredService(); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.ChildrenTodos).First(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.Contains(updatedTodoItem.ChildrenTodos, ti => ti.Id == todoItem.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.ChildrenTodos) + .Where(item => item.Id == todoItem.Id) + .FirstAsync(); + + todoItemInDatabase.ChildrenTodos.Should().HaveCount(2); + todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == todoItem.Id); + todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == otherTodoItem.Id); + }); } [Fact] @@ -109,52 +96,46 @@ public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() { // Arrange var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); - // Act - var content = new + var requestBody = new { data = new { type = "todoItems", - id = todoItem.Id, + id = todoItem.StringId, relationships = new Dictionary { + ["dependentOnTodo"] = new { - "dependentOnTodo", new - { - data = new {type = "todoItems", id = $"{todoItem.Id}"} - } + data = new {type = "todoItems", id = todoItem.StringId} } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - await client.SendAsync(request); - _context = _fixture.GetRequiredService(); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.DependentOnTodo).First(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.Equal(todoItem.Id, updatedTodoItem.DependentOnTodoId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.DependentOnTodo) + .Where(item => item.Id == todoItem.Id) + .FirstAsync(); + + todoItemInDatabase.DependentOnTodoId.Should().Be(todoItem.Id); + }); } [Fact] @@ -162,372 +143,260 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi { // Arrange var todoItem = _todoItemFaker.Generate(); - var strayTodoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - _context.TodoItems.Add(strayTodoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + var otherTodoItem = _todoItemFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(todoItem, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - // Act - var content = new + var requestBody = new { data = new { type = "todoItems", - id = todoItem.Id, + id = todoItem.StringId, relationships = new Dictionary { + ["dependentOnTodo"] = new { - "dependentOnTodo", new - { - data = new {type = "todoItems", id = $"{todoItem.Id}"} - } + data = new {type = "todoItems", id = todoItem.StringId} }, + ["childrenTodos"] = new { - "childrenTodos", new + data = new[] { - data = new object[] - { - new {type = "todoItems", id = $"{todoItem.Id}"}, - new {type = "todoItems", id = $"{strayTodoItem.Id}"} - } + new {type = "todoItems", id = todoItem.StringId}, + new {type = "todoItems", id = otherTodoItem.StringId} } } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - await client.SendAsync(request); - _context = _fixture.GetRequiredService(); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.ParentTodo).First(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.Equal(todoItem.Id, updatedTodoItem.ParentTodoId); - } + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.ParentTodo) + .Where(item => item.Id == todoItem.Id) + .FirstAsync(); + todoItemInDatabase.ParentTodoId.Should().Be(todoItem.Id); + }); + } + [Fact] public async Task Fails_When_Patching_Primary_Endpoint_With_Missing_Secondary_Resources() { // Arrange var todoItem = _todoItemFaker.Generate(); var person = _personFaker.Generate(); - _context.AddRange(todoItem, person); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(todoItem, person); + await dbContext.SaveChangesAsync(); + }); - // Act - var content = new + var requestBody = new { data = new { type = "todoItems", - id = todoItem.Id, + id = todoItem.StringId, relationships = new Dictionary { + ["stakeHolders"] = new { - "stakeHolders", new + data = new[] { - data = new[] - { - new { type = "people", id = person.StringId }, - new { type = "people", id = "900000" }, - new { type = "people", id = "900001" } - } + new {type = "people", id = person.StringId}, + new {type = "people", id = "900000"}, + new {type = "people", id = "900001"} } }, + ["parentTodo"] = new { - "parentTodo", new - { - data = new { type = "todoItems", id = "900002" } - } + data = new {type = "todoItems", id = "900002"} } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - AssertEqualStatusCode(HttpStatusCode.NotFound, response); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Equal(3, errorDocument.Errors.Count); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(3); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'people' with ID '900000' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[0].Detail); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '900000' being assigned to relationship 'stakeHolders' does not exist."); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[1].StatusCode); - Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[1].Title); - Assert.Equal("Resource of type 'people' with ID '900001' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[1].Detail); + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'people' with ID '900001' being assigned to relationship 'stakeHolders' does not exist."); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[2].StatusCode); - Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[2].Title); - Assert.Equal("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist.",errorDocument.Errors[2].Detail); + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[2].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[2].Detail.Should().Be("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist."); } [Fact] public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() { // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoCollection.Owner = person; - todoCollection.TodoItems.Add(todoItem); - _context.TodoItemCollections.Add(todoCollection); - await _context.SaveChangesAsync(); - - var newTodoItem1 = _todoItemFaker.Generate(); - var newTodoItem2 = _todoItemFaker.Generate(); - _context.AddRange(newTodoItem1, newTodoItem2); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new + var todoCollection = new TodoItemCollection { - data = new + Owner = _personFaker.Generate(), + TodoItems = new HashSet { - type = "todoCollections", - id = todoCollection.Id, - relationships = new Dictionary - { - { - "todoItems", new - { - data = new object[] - { - new {type = "todoItems", id = $"{newTodoItem1.Id}"}, - new {type = "todoItems", id = $"{newTodoItem2.Id}"} - } - } - } - } + _todoItemFaker.Generate() } }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoCollections/{todoCollection.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - var updatedTodoItems = _context.TodoItemCollections.AsNoTracking() - .Where(tic => tic.Id == todoCollection.Id) - .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - - - AssertEqualStatusCode(HttpStatusCode.OK, response); - // we are expecting two, not three, because the request does - // a "complete replace". - Assert.Equal(2, updatedTodoItems.Count); - } - - [Fact] - public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targets_Already_Attached() - { - // It is possible that resources we're creating relationships to - // have already been included in dbContext the application beyond control - // of JANDC. For example: a user may have been loaded when checking permissions - // in business logic in controllers. In this case, - // this user may not be reattached to the db context in the repository. - - // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoCollection.Owner = person; - todoCollection.Name = "PRE-ATTACH-TEST"; - todoCollection.TodoItems.Add(todoItem); - _context.TodoItemCollections.Add(todoCollection); - await _context.SaveChangesAsync(); - + var newTodoItem1 = _todoItemFaker.Generate(); var newTodoItem2 = _todoItemFaker.Generate(); - _context.AddRange(newTodoItem1, newTodoItem2); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(todoCollection, newTodoItem1, newTodoItem2); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { data = new { type = "todoCollections", - id = todoCollection.Id, - attributes = new - { - name = todoCollection.Name - }, + id = todoCollection.StringId, relationships = new Dictionary { + ["todoItems"] = new { - "todoItems", new + data = new[] { - data = new object[] - { - new {type = "todoItems", id = $"{newTodoItem1.Id}"}, - new {type = "todoItems", id = $"{newTodoItem2.Id}"} - } + new {type = "todoItems", id = newTodoItem1.StringId}, + new {type = "todoItems", id = newTodoItem2.StringId} } } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoCollections/{todoCollection.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoCollections/" + todoCollection.StringId; // Act - var response = await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - var updatedTodoItems = _context.TodoItemCollections.AsNoTracking() - .Where(tic => tic.Id == todoCollection.Id) - .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoCollectionInDatabase = await dbContext.TodoItemCollections + .Include(collection => collection.TodoItems) + .Where(collection => collection.Id == todoCollection.Id) + .FirstAsync(); - AssertEqualStatusCode(HttpStatusCode.OK, response); - // we are expecting two, not three, because the request does - // a "complete replace". - Assert.Equal(2, updatedTodoItems.Count); + todoCollectionInDatabase.TodoItems.Should().HaveCount(2); + }); } [Fact] public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overlap() { // Arrange - var todoCollection = new TodoItemCollection { TodoItems = new HashSet() }; - var person = _personFaker.Generate(); var todoItem1 = _todoItemFaker.Generate(); var todoItem2 = _todoItemFaker.Generate(); - todoCollection.Owner = person; - todoCollection.TodoItems.Add(todoItem1); - todoCollection.TodoItems.Add(todoItem2); - _context.TodoItemCollections.Add(todoCollection); - await _context.SaveChangesAsync(); - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); + var todoCollection = new TodoItemCollection + { + Owner = _personFaker.Generate(), + TodoItems = new HashSet + { + todoItem1, + todoItem2 + } + }; - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItemCollections.Add(todoCollection); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { data = new { type = "todoCollections", - id = todoCollection.Id, + id = todoCollection.StringId, relationships = new Dictionary { + ["todoItems"] = new { - "todoItems", new + data = new[] { - data = new object[] - { - new {type = "todoItems", id = $"{todoItem1.Id}"}, - new {type = "todoItems", id = $"{todoItem2.Id}"} - } + new {type = "todoItems", id = todoItem1.StringId}, + new {type = "todoItems", id = todoItem2.StringId} } } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoCollections/{todoCollection.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoCollections/" + todoCollection.StringId; // Act - var response = await client.SendAsync(request); - + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - _context = _fixture.GetRequiredService(); - var updatedTodoItems = _context.TodoItemCollections.AsNoTracking() - .Where(tic => tic.Id == todoCollection.Id) - .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoCollectionInDatabase = await dbContext.TodoItemCollections + .Include(collection => collection.TodoItems) + .Where(collection => collection.Id == todoCollection.Id) + .FirstAsync(); - AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(2, updatedTodoItems.Count); + todoCollectionInDatabase.TodoItems.Should().HaveCount(2); + }); } [Fact] public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() { // Arrange - var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - - _context.People.Add(person); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); + todoItem.Owner = _personFaker.Generate(); - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { data = new { - id = todoItem.Id, + id = todoItem.StringId, type = "todoItems", relationships = new { @@ -539,25 +408,23 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - var response = await client.SendAsync(request); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - var todoItemResult = _context.TodoItems - .AsNoTracking() - .Include(t => t.Owner) - .Single(t => t.Id == todoItem.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.Owner) + .Where(item => item.Id == todoItem.Id) + .FirstAsync(); - AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Null(todoItemResult.Owner); + todoItemInDatabase.Owner.Should().BeNull(); + }); } [Fact] @@ -565,138 +432,143 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() { // Arrange var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - person.TodoItems = new HashSet {todoItem}; - _context.People.Add(person); - await _context.SaveChangesAsync(); + person.TodoItems = new HashSet + { + _todoItemFaker.Generate() + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { data = new { - id = person.Id, + id = person.StringId, type = "people", relationships = new Dictionary { + ["todoItems"] = new { - "todoItems", new - { - data = new List() - } + data = new object[0] } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/people/" + person.StringId; // Act - var response = await _fixture.Client.SendAsync(request); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - var personResult = _context.People - .AsNoTracking() - .Include(p => p.TodoItems) - .Single(p => p.Id == person.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); - AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Empty(personResult.TodoItems); + personInDatabase.TodoItems.Should().BeEmpty(); + }); } [Fact] public async Task Updating_ToOne_Relationship_With_Implicit_Remove() { // Arrange - var context = _fixture.GetRequiredService(); - var passport = new Passport(context); var person1 = _personFaker.Generate(); - person1.Passport = passport; var person2 = _personFaker.Generate(); - context.People.AddRange(new List {person1, person2}); - await context.SaveChangesAsync(); - var passportId = person1.PassportId; - var content = new + + Passport passport = null; + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + + passport = new Passport(dbContext); + person1.Passport = passport; + + dbContext.People.AddRange(person1, person2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new { data = new { type = "people", - id = person2.Id, + id = person2.StringId, relationships = new Dictionary { + ["passport"] = new { - "passport", new - { - data = new {type = "passports", id = $"{passport.StringId}"} - } + data = new {type = "passports", id = passport.StringId} } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person2.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/people/" + person2.StringId; // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personsInDatabase = await dbContext.People + .Include(person => person.Passport) + .ToListAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, - $"{route} returned {response.StatusCode} status code with payload: {body}"); - var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("Passport") - .FirstOrDefault(); - Assert.Equal(passportId, dbPerson.Passport.Id); + personsInDatabase.Single(person => person.Id == person1.Id).Passport.Should().BeNull(); + personsInDatabase.Single(person => person.Id == person2.Id).Passport.Id.Should().Be(passport.Id); + }); } [Fact] public async Task Updating_ToMany_Relationship_With_Implicit_Remove() { // Arrange - var context = _fixture.GetRequiredService(); var person1 = _personFaker.Generate(); person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + var person2 = _personFaker.Generate(); person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - context.People.AddRange(new List {person1, person2}); - await context.SaveChangesAsync(); - var todoItem1Id = person1.TodoItems.ElementAt(0).Id; - var todoItem2Id = person1.TodoItems.ElementAt(1).Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(person1, person2); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { data = new { type = "people", - id = person2.Id, + id = person2.StringId, relationships = new Dictionary { + ["todoItems"] = new { - "todoItems", new + data = new[] { - data = new List + new + { + type = "todoItems", + id = person1.TodoItems.ElementAt(0).StringId + }, + new { - new - { - type = "todoItems", - id = $"{todoItem1Id}" - }, - new - { - type = "todoItems", - id = $"{todoItem2Id}" - } + type = "todoItems", + id = person1.TodoItems.ElementAt(1).StringId } } } @@ -704,27 +576,28 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person2.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/people/" + person2.StringId; // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.True(HttpStatusCode.OK == response.StatusCode, - $"{route} returned {response.StatusCode} status code with payload: {body}"); - var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("TodoItems") - .FirstOrDefault(); - Assert.Equal(2, dbPerson.TodoItems.Count); - Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem1Id)); - Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem2Id)); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personsInDatabase = await dbContext.People + .Include(person => person.TodoItems) + .ToListAsync(); + + // TODO: Should this even work? The test name suggests that it should... + //personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().BeEmpty(); + + var person2InDatabase = personsInDatabase.Single(person => person.Id == person2.Id); + person2InDatabase.TodoItems.Should().HaveCount(2); + person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(0).Id); + person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(1).Id); + }); } [Fact] @@ -733,51 +606,45 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); + + var otherTodoItem = _todoItemFaker.Generate(); - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { - data = new List + data = new[] { new { type = "todoItems", - id = $"{todoItem.Id}" + id = otherTodoItem.StringId } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; // Act - var response = await client.SendAsync(request); ; + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - _context = _fixture.GetRequiredService(); - var assertTodoItems = _context.People.Include(p => p.TodoItems) - .Single(p => p.Id == person.Id).TodoItems; + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.Single(assertTodoItems); - Assert.Equal(todoItem.Id, assertTodoItems.ElementAt(0).Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(1); + personInDatabase.TodoItems.ElementAt(0).Id.Should().Be(otherTodoItem.Id); + }); } [Fact] @@ -785,35 +652,40 @@ public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); + var otherTodoItem = _todoItemFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + var requestBody = new + { + data = new + { + type = "people", id = person.StringId + } + }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = $"/api/v1/todoItems/{otherTodoItem.StringId}/relationships/owner"; // Act - var response = await client.SendAsync(request); - var todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.NotNull(todoItemsOwner); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.Owner) + .Where(item => item.Id == otherTodoItem.Id) + .FirstAsync(); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(person.Id); + }); } [Fact] @@ -823,40 +695,34 @@ public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpo var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new + await _testContext.RunOnDatabaseAsync(async dbContext => { - data = (object) null - }; + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) + var requestBody = new { - Content = new StringContent(JsonConvert.SerializeObject(content)) + data = (object) null }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/owner"; // Act - var response = await client.SendAsync(request); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - var todoItemResult = _context.TodoItems - .AsNoTracking() - .Include(t => t.Owner) - .Single(t => t.Id == todoItem.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Null(todoItemResult.Owner); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.Owner) + .Where(item => item.Id == todoItem.Id) + .FirstAsync(); + + todoItemInDatabase.Owner.Should().BeNull(); + }); } [Fact] @@ -865,51 +731,45 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + + var otherTodoItem = _todoItemFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - var content = new + var requestBody = new { - data = new List + data = new[] { new { type = "todoItems", - id = $"{todoItem.Id}" + id = otherTodoItem.StringId } } }; - var httpMethod = new HttpMethod("POST"); - var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; // Act - var response = await client.SendAsync(request); + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - _context = _fixture.GetRequiredService(); - var assertTodoItems = _context.People.Include(p => p.TodoItems) - .Single(p => p.Id == person.Id).TodoItems; + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.Equal(4, assertTodoItems.Count); - Assert.Contains(todoItem, assertTodoItems, IdentifiableComparer.Instance); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(4); + personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == otherTodoItem.Id); + }); } [Fact] @@ -918,50 +778,45 @@ public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_En // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - _context.People.Add(person); - - await _context.SaveChangesAsync(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + var todoItemToDelete = person.TodoItems.ElementAt(0); - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new + var requestBody = new { - data = new List + data = new[] { new { type = "todoItems", - id = $"{todoItemToDelete.Id}" + id = todoItemToDelete.StringId } } }; - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; // Act - var response = await client.SendAsync(request); + var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - _context = _fixture.GetRequiredService(); - var assertTodoItems = _context.People.AsNoTracking().Include(p => p.TodoItems) - .Single(p => p.Id == person.Id).TodoItems; - - Assert.Equal(2, assertTodoItems.Count); - var deletedTodoItem = assertTodoItems.SingleOrDefault(ti => ti.Id == todoItemToDelete.Id); - Assert.Null(deletedTodoItem); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(2); + personInDatabase.TodoItems.Should().NotContain(item => item.Id == todoItemToDelete.Id); + }); } [Fact] @@ -969,39 +824,34 @@ public async Task Fails_When_Patching_Relationships_Endpoint_With_Unknown_Relati { // Arrange var person = _personFaker.Generate(); - _context.People.Add(person); - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, todoItem); + await dbContext.SaveChangesAsync(); + }); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + var requestBody = new + { + data = new + { + type = "people", id = person.StringId + } + }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/invalid"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.NotFound, response); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.", - errorDocument.Errors[0].Detail); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'invalid'."); } [Fact] @@ -1009,129 +859,118 @@ public async Task Fails_When_Patching_Relationships_Endpoint_With_Missing_Primar { // Arrange var person = _personFaker.Generate(); - _context.People.Add(person); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); + var requestBody = new + { + data = new + { + type = "people", id = person.StringId + } + }; - var httpMethod = new HttpMethod("PATCH"); var route = "/api/v1/todoItems/99999999/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.NotFound, response); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.", - errorDocument.Errors[0].Detail); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' with ID '99999999' does not exist."); } - - [Fact] + + [Fact] public async Task Fails_When_Posting_To_Many_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resources() { // Arrange var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - var person = _personFaker.Generate(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var missingPerson1 = _personFaker.Generate(); - missingPerson1.Id = 9999998; - var missingPerson2 = _personFaker.Generate(); - missingPerson2.Id = 9999999; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(todoItem, person); + await dbContext.SaveChangesAsync(); + }); - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(new [] { person, missingPerson1, missingPerson2 }); + var requestBody = new + { + data = new[] + { + new + { + type = "people", id = person.StringId + }, + new + { + type = "people", id = "9999000" + }, + new + { + type = "people", id = "9999111" + } + } + }; - var httpMethod = new HttpMethod("PATCH"); var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/stakeHolders"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.NotFound, response); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Equal(2, errorDocument.Errors.Count); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.Should().HaveCount(2); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'people' with ID '9999998' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[0].Detail); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '9999000' being assigned to relationship 'stakeHolders' does not exist."); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[1].StatusCode); - Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[1].Title); - Assert.Equal("Resource of type 'people' with ID '9999999' being assigned to relationship 'stakeHolders' does not exist.",errorDocument.Errors[1].Detail); + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'people' with ID '9999111' being assigned to relationship 'stakeHolders' does not exist."); } - + [Fact] public async Task Fails_When_Patching_To_One_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resource() -{ + { // Arrange var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var missingPerson = _personFaker.Generate(); - missingPerson.Id = 9999999; - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(missingPerson); + var requestBody = new + { + data = new + { + type = "people", id = "9999999" + } + }; - var httpMethod = new HttpMethod("PATCH"); var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.NotFound, response); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("A resource being assigned to a relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'people' with ID '9999999' being assigned to relationship 'owner' does not exist.",errorDocument.Errors[0].Detail); -} - - private void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '9999999' being assigned to relationship 'owner' does not exist."); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index 44a347fc63..428c44586d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -120,9 +120,9 @@ public async Task RunOnDatabaseAsync(Func asyncAction) } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteDeleteAsync(string requestUrl) + ExecuteDeleteAsync(string requestUrl, object requestBody = null) { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl); + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody); } private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 2dbc6881c9..eebbb3ede2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -58,11 +58,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertMan = await dbContext.Men + var manInDatabase = await dbContext.Men .Include(m => m.HealthInsurance) .SingleAsync(m => m.Id == int.Parse(responseDocument.SingleData.Id)); - assertMan.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Should().BeOfType(); }); } @@ -95,11 +95,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertMan = await dbContext.Men + var manInDatabase = await dbContext.Men .Include(m => m.HealthInsurance) .SingleAsync(h => h.Id == man.Id); - assertMan.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Should().BeOfType(); }); } @@ -148,13 +148,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertMan = await dbContext.Men + var manInDatabase = await dbContext.Men .Include(m => m.Parents) .SingleAsync(m => m.Id == int.Parse(responseDocument.SingleData.Id)); - assertMan.Parents.Should().HaveCount(2); - assertMan.Parents.Should().ContainSingle(h => h is Man); - assertMan.Parents.Should().ContainSingle(h => h is Woman); + manInDatabase.Parents.Should().HaveCount(2); + manInDatabase.Parents.Should().ContainSingle(h => h is Man); + manInDatabase.Parents.Should().ContainSingle(h => h is Woman); }); } @@ -191,13 +191,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertChild = await dbContext.Men + var manInDatabase = await dbContext.Men .Include(m => m.Parents) .SingleAsync(m => m.Id == child.Id); - assertChild.Parents.Should().HaveCount(2); - assertChild.Parents.Should().ContainSingle(h => h is Man); - assertChild.Parents.Should().ContainSingle(h => h is Woman); + manInDatabase.Parents.Should().HaveCount(2); + manInDatabase.Parents.Should().ContainSingle(h => h is Man); + manInDatabase.Parents.Should().ContainSingle(h => h is Woman); }); } From 140cb529af902324fca4dc1234c31673e1aaf2f6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 11:06:44 +0200 Subject: [PATCH 060/240] fixed broken test --- .../Acceptance/Spec/UpdatingRelationshipsTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index cc462ac882..34d6d18387 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -590,8 +590,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(person => person.TodoItems) .ToListAsync(); - // TODO: Should this even work? The test name suggests that it should... - //personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().BeEmpty(); + personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().HaveCount(1); var person2InDatabase = personsInDatabase.Single(person => person.Id == person2.Id); person2InDatabase.TodoItems.Should().HaveCount(2); From 754a05e7a3d730c929f47b66d51a84fbec1d62cb Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 11:06:44 +0200 Subject: [PATCH 061/240] fixed broken test --- .../Repositories/EntityFrameworkCoreRepository.cs | 2 +- .../Acceptance/Spec/UpdatingDataTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a21abf6cf2..4d784807be 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -378,7 +378,7 @@ private async Task LoadInverseRelationshipsInChangeTracker(RelationshipAttribute } else if (relationship is HasManyThroughAttribute) { - // TODO: What should happen in this case? + // Do nothing. Implicit removal is not possible for many-to-many relationships. } else { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 2b441b9fef..da1cdcf003 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -447,7 +447,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = todoItem.StringId, attributes = new Dictionary { - ["description"] = "Something else", + ["description"] = "Something else" }, relationships = new Dictionary { @@ -524,7 +524,7 @@ public async Task Demo_Composite_Key_Navigation_Assignment() var car = new Car { RegionId = 123, - LicensePlate = "AA-BB-11", + LicensePlate = "AA-BB-11" }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -566,7 +566,7 @@ public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key Car = new Car { RegionId = 123, - LicensePlate = "AA-BB-11", + LicensePlate = "AA-BB-11" } }; From 25391263f299504126d45eb35097b1fa36936bea Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 11:51:49 +0200 Subject: [PATCH 062/240] refactored inverse navigation handling --- .../IInverseRelationshipResolver.cs | 4 +- .../InverseRelationshipResolver.cs | 4 +- .../Configuration/ResourceGraph.cs | 10 +++-- .../Hooks/Internal/ResourceHookExecutor.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 39 ++++++++----------- .../Annotations/RelationshipAttribute.cs | 9 +++-- 6 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs b/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs index 589cae301d..c9e4e10722 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs @@ -3,13 +3,13 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Responsible for populating the property. + /// Responsible for populating the property. /// /// This service is instantiated in the configure phase of the application. /// /// When using a data access layer different from EF Core, and when using ResourceHooks /// that depend on the inverse navigation property (BeforeImplicitUpdateRelationship), - /// you will need to override this service, or pass along the InverseRelationshipPropertyName in + /// you will need to override this service, or pass along the InverseNavigationProperty in /// the RelationshipAttribute. /// public interface IInverseRelationshipResolver diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs index 86ec4932fc..12491d38cd 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs @@ -41,10 +41,8 @@ private void Resolve(DbContext dbContext) { if (!(relationship is HasManyThroughAttribute)) { - // TODO: Replace Relationship.InverseRelationshipPropertyName (string) with RelationShip.InverseRelationship object that we assign here. - INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.FindInverse(); - relationship.InverseRelationshipPropertyName = inverseNavigation?.Name; + relationship.InverseNavigationProperty = inverseNavigation?.PropertyInfo; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 89d0f08b18..deb64895dd 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -90,10 +90,14 @@ public RelationshipAttribute GetInverseRelationship(RelationshipAttribute relati { if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationship.InverseRelationshipPropertyName == null) return null; + if (relationship.InverseNavigationProperty == null) + { + return null; + } + return GetResourceContext(relationship.RightType) - .Relationships - .SingleOrDefault(r => r.Property.Name == relationship.InverseRelationshipPropertyName); + .Relationships + .SingleOrDefault(r => r.Property == relationship.InverseNavigationProperty); } private IReadOnlyCollection Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index 3974d75021..f2b77c10fa 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -337,7 +337,7 @@ private Dictionary ReplaceKeysWithInverseRel // that the inverse attribute was also set (Owner has one Article: HasOneAttr:article). // If it isn't, JADNC currently knows nothing about this relationship pointing back, and it // currently cannot fire hooks for resources resolved through inverse relationships. - var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseRelationshipPropertyName != null); + var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseNavigationProperty != null); return inversableRelationshipAttributes.ToDictionary(kvp => _resourceGraph.GetInverseRelationship(kvp.Key), kvp => kvp.Value); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 4d784807be..fe44d6963e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -361,19 +361,25 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute /// private async Task LoadInverseRelationshipsInChangeTracker(RelationshipAttribute relationship, object resource) { - if (relationship.InverseRelationshipPropertyName != null) + if (relationship.InverseNavigationProperty != null) { if (relationship is HasOneAttribute hasOneRelationship) { var entityEntry = _dbContext.Entry(resource); - - if (IsOneToOne(hasOneRelationship)) + + var isOneToOne = IsOneToOne(hasOneRelationship); + + if (isOneToOne == true) + { + await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); + } + else if (isOneToOne == false) { - await entityEntry.Reference(relationship.InverseRelationshipPropertyName).LoadAsync(); + await entityEntry.Collection(relationship.InverseNavigationProperty.Name).LoadAsync(); } else { - await entityEntry.Collection(relationship.InverseRelationshipPropertyName).LoadAsync(); + // TODO: What should happen if no inverse navigation exists? } } else if (relationship is HasManyThroughAttribute) @@ -387,32 +393,21 @@ private async Task LoadInverseRelationshipsInChangeTracker(RelationshipAttribute foreach (var nextResource in resources) { var nextEntityEntry = _dbContext.Entry(nextResource); - await nextEntityEntry.Reference(relationship.InverseRelationshipPropertyName).LoadAsync(); + await nextEntityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); } } } } - private bool IsOneToOne(HasOneAttribute hasOneRelationship) + private bool? IsOneToOne(HasOneAttribute hasOneRelationship) { - var inverseRelationship = _resourceGraph - .GetRelationships(hasOneRelationship.RightType) - .FirstOrDefault(r => r.Property.Name == hasOneRelationship.InverseRelationshipPropertyName); - - if (inverseRelationship != null) + if (hasOneRelationship.InverseNavigationProperty != null) { - return inverseRelationship is HasOneAttribute; + var inversePropertyIsCollection = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType) != null; + return !inversePropertyIsCollection; } - // inverseRelationship is null when there is no RelationshipAttribute on the inverse navigation property. - // In this case we reflect on the type to figure out what kind of relationship is pointing back. - - // TODO: If there is no InverseRelationshipPropertyName, I don't think the next line ever matches anything. - // On the other hand, if there is one, then we would have found it in the lines above. - var inverseProperty = hasOneRelationship.RightType.GetProperty(hasOneRelationship.InverseRelationshipPropertyName).PropertyType; - - var isCollection = TypeHelper.IsOrImplementsInterface(inverseProperty, typeof(IEnumerable)); - return !isCollection; + return null; } private async Task AssignValueToRelationship(RelationshipAttribute relationship, TResource leftResource, diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index 0178e32b98..4b9e02410d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -12,24 +13,24 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute private LinkTypes _links; /// - /// The property name of the inverse relationship, if any. + /// The property name of the EF Core inverse navigation, which may or may not be exposed as a json:api relationship. /// /// /// Articles { get; set; } /// } /// ]]> /// - public string InverseRelationshipPropertyName { get; internal set; } + internal PropertyInfo InverseNavigationProperty { get; set; } /// /// The internal navigation property path to the related resource. From 8ba2cf626c2109707d3bf66d793febbd3687c6ad Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 15 Oct 2020 16:10:38 +0200 Subject: [PATCH 063/240] fix: broken tests except for missing primary resource test --- .../Data/AppDbContext.cs | 3 ++ .../JsonApiDotNetCoreExample/Models/Car.cs | 50 +++++++++---------- .../Repositories/CarRepository.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 23 +++++---- .../Acceptance/Spec/UpdatingDataTests.cs | 48 +----------------- 5 files changed, 41 insertions(+), 85 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 9e3c6b256a..b898012159 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -96,8 +96,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(p => p.OneToOnePersonId); modelBuilder.Entity() + .Ignore(c => c.Id) + .Ignore(c => c.StringId) .HasKey(c => new { c.RegionId, c.LicensePlate }); + modelBuilder.Entity() .HasOne(e => e.Car) .WithOne(c => c.Engine) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs index f557caf89a..947824dd96 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs @@ -5,43 +5,39 @@ namespace JsonApiDotNetCoreExample.Models { - public sealed class Car : Identifiable + public sealed class Car : IIdentifiable { - [NotMapped] - public override string Id + public string Id { - get => base.Id; - set => base.Id = value; - } - - protected override string GetStringId(string value) - { - return $"{RegionId}:{LicensePlate}"; - } - - protected override string GetTypedId(string value) - { - var elements = value.Split(':'); - if (elements.Length == 2) + get => $"{RegionId}:{LicensePlate}"; + set { - if (int.TryParse(elements[0], out int regionId)) + var elements = value.Split(':'); + if (elements.Length == 2) + { + if (int.TryParse(elements[0], out int regionId)) + { + RegionId = regionId; + LicensePlate = elements[1]; + } + } + else { - RegionId = regionId; - LicensePlate = elements[1]; - return value; + throw new InvalidOperationException($"Failed to convert ID '{value}'."); } } + } - throw new InvalidOperationException($"Failed to convert ID '{value}'."); + public string StringId + { + get => Id; + set => Id = value; } - [Attr] - public string LicensePlate { get; set; } + [Attr] public string LicensePlate { get; set; } - [Attr] - public long RegionId { get; set; } + [Attr] public long? RegionId { get; set; } - [HasOne] - public Engine Engine { get; set; } + [HasOne] public Engine Engine { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs index e9c56e8b88..8b731d912f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs @@ -102,7 +102,7 @@ private QueryExpression RewriteEqualityComparisonForCarStringId(string carString return CreateEqualityComparisonOnRegionIdLicensePlate(tempCar.RegionId, tempCar.LicensePlate); } - private QueryExpression CreateEqualityComparisonOnRegionIdLicensePlate(long regionIdValue, + private QueryExpression CreateEqualityComparisonOnRegionIdLicensePlate(long? regionIdValue, string licensePlateValue) { var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a21abf6cf2..510261d9c2 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -165,17 +165,18 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) var relationship = _targetedFields.Relationships.Single(); - TResource primaryResource; + TResource primaryResource = CreateInstanceWithAssignedId(id); if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtSideOfHasOneRelationship(hasOneRelationship)) { - primaryResource = await _dbContext.Set() + var primaryResourceFromDatabase = await _dbContext.Set() .Include(relationship.Property.Name) .Where(r => r.Id.Equals(id)) - .FirstAsync(); + .FirstOrDefaultAsync(); + primaryResource = primaryResourceFromDatabase ?? primaryResource; } else { - primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreateInstanceWithAssignedId(id)); + primaryResource = (TResource) _dbContext.GetTrackedOrAttach(primaryResource); await LoadRelationship(primaryResource, relationship); } @@ -190,12 +191,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r _traceWriter.LogMethodStart(new {resourceFromRequest, resourceFromDatabase}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); - - foreach (var attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); - } - + foreach (var relationship in _targetedFields.Relationships) { if (relationship is HasOneAttribute hasOneRelationship && @@ -222,6 +218,11 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r //_dbContext.Entry(resourceFromDatabase).State = EntityState.Modified; } + + foreach (var attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); + } await SaveChangesAsync(); } @@ -454,7 +455,7 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, } }*/ } - + relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 2b441b9fef..8cb998f0b7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; @@ -510,52 +511,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(car.StringId); } - - // TODO: Remove temporary code for experiments. - [Fact] - public async Task Demo_Composite_Key_Navigation_Assignment() - { - // Arrange - var engine = new Engine - { - SerialCode = "1234567890" - }; - - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11", - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTablesAsync(); - dbContext.AddRange(engine, car); - await dbContext.SaveChangesAsync(); - }); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - engine = await dbContext.Engines.FirstAsync(e => e.Id.Equals(engine.Id)); - car = await dbContext.Cars.FirstAsync(c => c.RegionId == car.RegionId && c.LicensePlate == car.LicensePlate); - - // Act - engine.Car = car; - - await dbContext.SaveChangesAsync(); - }); - - // Assert - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var engineInDatabase = await dbContext.Engines - .Include(e => e.Car) - .SingleAsync(e => e.Id == engine.Id); - - engineInDatabase.Car.Should().NotBeNull(); - }); - } - + [Fact] public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key() { From ac0eec2ef99883fd54eaf29d67eb174bc75a488d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 16:27:30 +0200 Subject: [PATCH 064/240] cleanup --- .../Data/AppDbContext.cs | 3 --- .../JsonApiDotNetCoreExample/Models/Car.cs | 20 +++++++++---------- .../Repositories/CarRepository.cs | 2 +- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index b898012159..9e3c6b256a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -96,11 +96,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(p => p.OneToOnePersonId); modelBuilder.Entity() - .Ignore(c => c.Id) - .Ignore(c => c.StringId) .HasKey(c => new { c.RegionId, c.LicensePlate }); - modelBuilder.Entity() .HasOne(e => e.Car) .WithOne(c => c.Engine) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs index 947824dd96..991acff7f3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs @@ -5,9 +5,10 @@ namespace JsonApiDotNetCoreExample.Models { - public sealed class Car : IIdentifiable + public sealed class Car : Identifiable { - public string Id + [NotMapped] + public override string Id { get => $"{RegionId}:{LicensePlate}"; set @@ -28,16 +29,13 @@ public string Id } } - public string StringId - { - get => Id; - set => Id = value; - } - - [Attr] public string LicensePlate { get; set; } + [Attr] + public string LicensePlate { get; set; } - [Attr] public long? RegionId { get; set; } + [Attr] + public long RegionId { get; set; } - [HasOne] public Engine Engine { get; set; } + [HasOne] + public Engine Engine { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs index 8b731d912f..e9c56e8b88 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs @@ -102,7 +102,7 @@ private QueryExpression RewriteEqualityComparisonForCarStringId(string carString return CreateEqualityComparisonOnRegionIdLicensePlate(tempCar.RegionId, tempCar.LicensePlate); } - private QueryExpression CreateEqualityComparisonOnRegionIdLicensePlate(long? regionIdValue, + private QueryExpression CreateEqualityComparisonOnRegionIdLicensePlate(long regionIdValue, string licensePlateValue) { var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, From 92f7a92621be3861bf711647dda0babc2b80a558 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 16:39:26 +0200 Subject: [PATCH 065/240] fix test --- .../Repositories/EntityFrameworkCoreRepository.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7f6b524dd3..d7b12b7afa 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -172,7 +173,14 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) .Include(relationship.Property.Name) .Where(r => r.Id.Equals(id)) .FirstOrDefaultAsync(); - primaryResource = primaryResourceFromDatabase ?? primaryResource; + + if (primaryResourceFromDatabase == null) + { + var resourceContext = _resourceGraph.GetResourceContext(); + throw new ResourceNotFoundException(primaryResource.StringId, resourceContext.PublicName); + } + + primaryResource = primaryResourceFromDatabase; } else { From 6cff598089f2d5dad417fbc741cbdb40bd6e5130 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 17:18:47 +0200 Subject: [PATCH 066/240] renames --- .../Repositories/EntityFrameworkCoreRepository.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d7b12b7afa..fd449c4d1b 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -167,7 +167,7 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) var relationship = _targetedFields.Relationships.Single(); TResource primaryResource = CreateInstanceWithAssignedId(id); - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtSideOfHasOneRelationship(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSideOfHasOneRelationship(hasOneRelationship)) { var primaryResourceFromDatabase = await _dbContext.Set() .Include(relationship.Property.Name) @@ -203,7 +203,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (var relationship in _targetedFields.Relationships) { if (relationship is HasOneAttribute hasOneRelationship && - HasForeignKeyAtSideOfHasOneRelationship(hasOneRelationship)) + HasForeignKeyAtLeftSideOfHasOneRelationship(hasOneRelationship)) { FlushFromCache(resourceFromDatabase); @@ -368,7 +368,7 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute /// these "inverse relationships" into the DbContext ensures EF Core to take this into account. /// /// - private async Task LoadInverseRelationshipsInChangeTracker(RelationshipAttribute relationship, object resource) + private async Task LoadInverseRelationships(RelationshipAttribute relationship, object resource) { if (relationship.InverseNavigationProperty != null) { @@ -430,7 +430,7 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); // Ensures successful handling of implicit removals of relationships. - await LoadInverseRelationshipsInChangeTracker(relationship, trackedValueToAssign); + await LoadInverseRelationships(relationship, trackedValueToAssign); } if (relationship is HasOneAttribute hasOneRelationship) @@ -496,7 +496,7 @@ private object EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollectio return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private bool HasForeignKeyAtSideOfHasOneRelationship(HasOneAttribute relationship) + private bool HasForeignKeyAtLeftSideOfHasOneRelationship(HasOneAttribute relationship) { var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); var navigation = entityType.FindNavigation(relationship.Property.Name); From c5e67a7014d9246b9311c7488919252c11c2e331 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 15 Oct 2020 21:02:05 +0200 Subject: [PATCH 067/240] revert diff --- .../IntegrationTests/ResourceInheritance/Models/Human.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs index f719f0f9b2..fdbb27bfe2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs @@ -10,11 +10,12 @@ public abstract class Human : Identifiable [Attr] public bool Retired { get; set; } - [HasOne] public HealthInsurance HealthInsurance { get; set; } + [HasOne] + public HealthInsurance HealthInsurance { get; set; } [HasMany] public ICollection Parents { get; set; } - + [NotMapped] [HasManyThrough(nameof(HumanFavoriteContentItems))] public ICollection FavoriteContent { get; set; } From e23b3682a03b2dd0cdead3ee9cce252b721b1b11 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 15 Oct 2020 22:30:53 +0200 Subject: [PATCH 068/240] feat: remove excess inverse loading logic --- .../EntityFrameworkCoreRepository.cs | 41 ++----------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index fd449c4d1b..c2af97e25a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -360,50 +360,15 @@ protected async Task LoadRelationship(TResource resource, RelationshipAttribute /// /// Loads the inverse relationships to prevent foreign key constraints from being violated /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// - /// Consider the following example: - /// person.todoItems = [t1, t2] is updated to [t3, t4]. If t3 and/or t4 was - /// already related to another person, and these persons are NOT loaded into the - /// DbContext, then the query may fail with a foreign key constraint violation. Loading - /// these "inverse relationships" into the DbContext ensures EF Core to take this into account. - /// /// private async Task LoadInverseRelationships(RelationshipAttribute relationship, object resource) { if (relationship.InverseNavigationProperty != null) { - if (relationship is HasOneAttribute hasOneRelationship) + if (relationship is HasOneAttribute hasOneRelationship && IsOneToOne(hasOneRelationship) == true) { - var entityEntry = _dbContext.Entry(resource); - - var isOneToOne = IsOneToOne(hasOneRelationship); - - if (isOneToOne == true) - { - await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); - } - else if (isOneToOne == false) - { - await entityEntry.Collection(relationship.InverseNavigationProperty.Name).LoadAsync(); - } - else - { - // TODO: What should happen if no inverse navigation exists? - } - } - else if (relationship is HasManyThroughAttribute) - { - // Do nothing. Implicit removal is not possible for many-to-many relationships. - } - else - { - var resources = (IEnumerable)resource; - - foreach (var nextResource in resources) - { - var nextEntityEntry = _dbContext.Entry(nextResource); - await nextEntityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); - } + var entityEntry = _dbContext.Entry(resource); + await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); } } } From 230d712aa5821e4e3199fd2cc18b6b9d966ba52f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 00:38:39 +0200 Subject: [PATCH 069/240] Refactored CreatingDataTests to use new syntax --- .../Acceptance/Spec/CreatingDataTests.cs | 748 +++++++++++++----- .../Spec/UpdatingRelationshipsTests.cs | 5 - 2 files changed, 548 insertions(+), 205 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index ba787d5916..fe8dbb7da7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -3,391 +3,739 @@ using System.Net; using System.Threading.Tasks; using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { - public sealed class CreatingDataTests : FunctionalTestCollection + public sealed class CreatingDataTests : IClassFixture> { - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; + private readonly IntegrationTestContext _testContext; - public CreatingDataTests(StandardApplicationFactory factory) : base(factory) + private readonly Faker _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()) + .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + + private readonly Faker _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); + + public CreatingDataTests(IntegrationTestContext testContext) { - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = false; } [Fact] public async Task CreateResource_ModelWithEntityFrameworkInheritance_IsCreated() { // Arrange - var serializer = GetSerializer(e => new { e.SecurityLevel, e.UserName, e.Password }); - var superUser = new SuperUser(_dbContext) { SecurityLevel = 1337, UserName = "Super", Password = "User" }; + var requestBody = new + { + data = new + { + type = "superUsers", + attributes = new + { + securityLevel = 1337, + userName = "Jack", + password = "secret" + } + } + }; + + var route = "/api/v1/superUsers"; // Act - var (body, response) = await Post("/api/v1/superUsers", serializer.Serialize(superUser)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var createdSuperUser = _deserializer.DeserializeSingle(body).Data; - var first = _dbContext.Set().FirstOrDefault(e => e.Id.Equals(createdSuperUser.Id)); - Assert.NotNull(first); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["securityLevel"].Should().Be(1337); + responseDocument.SingleData.Attributes["userName"].Should().Be("Jack"); + + var newSuperUserId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var superUserInDatabase = await dbContext.Set() + .Where(superUser => superUser.Id == int.Parse(newSuperUserId)) + .SingleAsync(); + + superUserInDatabase.Password.Should().Be("secret"); + }); } [Fact] public async Task CreateResource_GuidResource_IsCreated() { // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.Owner }); - var owner = new Person(); - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - var todoItemCollection = new TodoItemCollection { Owner = owner }; + var requestBody = new + { + data = new + { + type = "todoCollections", + attributes = new + { + name = "Jack" + } + } + }; + + var route = "/api/v1/todoCollections"; // Act - var (_, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoItemCollection)); + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); } [Fact] public async Task ClientGeneratedId_IntegerIdAndNotEnabled_IsForbidden() { // Arrange - var serializer = GetSerializer(e => new { e.Description, e.Ordinal, e.CreatedDate }); - var todoItem = _todoItemFaker.Generate(); - const int clientDefinedId = 9999; - todoItem.Id = clientDefinedId; + var requestBody = new + { + data = new + { + type = "todoItems", + id = "9999", + attributes = new + { + description = "some", + } + } + }; + + var route = "/api/v1/todoItems"; // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("Specifying the resource ID in POST requests is not allowed.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] public async Task CreateWithRelationship_HasMany_IsCreated() { // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.TodoItems }); - var todoItem = _todoItemFaker.Generate(); - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - var todoCollection = new TodoItemCollection { TodoItems = new HashSet { todoItem } }; + var existingTodoItem = _todoItemFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoCollections", + relationships = new + { + todoItems = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + } + } + } + }; + + var route = "/api/v1/todoCollections"; // Act - var (body, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoCollection)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var contextCollection = GetDbContext().TodoItemCollections.AsNoTracking() - .Include(c => c.Owner) - .Include(c => c.TodoItems) - .SingleOrDefault(c => c.Id == responseItem.Id); - - Assert.NotEmpty(contextCollection.TodoItems); - Assert.Equal(todoItem.Id, contextCollection.TodoItems.First().Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newTodoCollectionId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoCollectionsInDatabase = await dbContext.TodoItemCollections + .Include(collection => collection.TodoItems) + .ToListAsync(); + + var newTodoCollectionInDatabase = todoCollectionsInDatabase.Single(c => c.StringId == newTodoCollectionId); + newTodoCollectionInDatabase.TodoItems.Should().HaveCount(1); + newTodoCollectionInDatabase.TodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); } [Fact] public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncluded() { // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.TodoItems, e.Owner }); - var owner = new Person(); - var todoItem = new TodoItem { Owner = owner, Description = "Description" }; - _dbContext.People.Add(owner); - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - var todoCollection = new TodoItemCollection { Owner = owner, TodoItems = new HashSet { todoItem } }; + var existingTodoItem = _todoItemFaker.Generate(); + existingTodoItem.Owner = _personFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoCollections", + relationships = new + { + todoItems = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + } + } + } + }; + + var route = "/api/v1/todoCollections?include=todoItems"; // Act - var (body, response) = await Post("/api/v1/todoCollections?include=todoItems", serializer.Serialize(todoCollection)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.NotNull(responseItem); - Assert.NotEmpty(responseItem.TodoItems); - Assert.Equal(todoItem.Description, responseItem.TodoItems.Single().Description); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("todoItems"); + responseDocument.Included[0].Id.Should().Be(existingTodoItem.StringId); + responseDocument.Included[0].Attributes["description"].Should().Be(existingTodoItem.Description); } [Fact] public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCreatedAndIncluded() { // Arrange - var serializer = GetSerializer(e => new { e.Name }, e => new { e.TodoItems, e.Owner }); - var owner = new Person(); - var todoItem = new TodoItem { Owner = owner, Ordinal = 123, Description = "Description" }; - _dbContext.People.Add(owner); - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - var todoCollection = new TodoItemCollection {Owner = owner, Name = "Jack", TodoItems = new HashSet {todoItem}}; + var existingTodoItem = _todoItemFaker.Generate(); + existingTodoItem.Owner = _personFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoCollections", + attributes = new + { + name = "Jack" + }, + relationships = new + { + todoItems = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }, + owner = new + { + data = new + { + type = "people", + id = existingTodoItem.Owner.StringId + } + } + } + } + }; + + var route = "/api/v1/todoCollections?include=todoItems&fields=name&fields[todoItems]=ordinal"; // Act - var (body, response) = await Post("/api/v1/todoCollections?include=todoItems&fields=name&fields[todoItems]=ordinal", serializer.Serialize(todoCollection)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.NotNull(responseItem); - Assert.Equal(todoCollection.Name, responseItem.Name); - - Assert.NotEmpty(responseItem.TodoItems); - Assert.Equal(todoItem.Ordinal, responseItem.TodoItems.Single().Ordinal); - Assert.Null(responseItem.TodoItems.Single().Description); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be("Jack"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("todoItems"); + responseDocument.Included[0].Id.Should().Be(existingTodoItem.StringId); + responseDocument.Included[0].Attributes["ordinal"].Should().Be(existingTodoItem.Ordinal); + responseDocument.Included[0].Attributes.Should().NotContainKey("description"); } [Fact] public async Task CreateWithRelationship_HasOne_IsCreated() { // Arrange - var serializer = GetSerializer(attributes: ti => new { }, relationships: ti => new { ti.Owner }); - var todoItem = new TodoItem(); - var owner = new Person(); - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - todoItem.Owner = owner; + var existingOwner = _personFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingOwner); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingOwner.StringId + } + } + } + } + }; + + var route = "/api/v1/todoItems"; // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var todoItemResult = GetDbContext().TodoItems.AsNoTracking() - .Include(c => c.Owner) - .SingleOrDefault(c => c.Id == responseItem.Id); - Assert.Equal(owner.Id, todoItemResult.Owner.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newTodoItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.Owner) + .Where(item => item.Id == int.Parse(newTodoItemId)) + .SingleAsync(); + + todoItemInDatabase.Owner.Id.Should().Be(existingOwner.Id); + }); } [Fact] public async Task CreateWithRelationship_HasOneAndInclude_IsCreatedAndIncluded() { // Arrange - var serializer = GetSerializer(attributes: ti => new { }, relationships: ti => new { ti.Owner }); - var todoItem = new TodoItem(); - var owner = new Person { FirstName = "Alice" }; - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - todoItem.Owner = owner; + var existingOwner = _personFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingOwner); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = "some" + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingOwner.StringId + } + } + } + } + }; + + var route = "/api/v1/todoItems?include=owner"; // Act - var (body, response) = await Post("/api/v1/todoItems?include=owner", serializer.Serialize(todoItem)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.NotNull(responseItem); - Assert.NotNull(responseItem.Owner); - Assert.Equal(owner.FirstName, responseItem.Owner.FirstName); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be("some"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(existingOwner.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingOwner.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingOwner.LastName); } [Fact] public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCreatedAndIncluded() { // Arrange - var serializer = GetSerializer(attributes: ti => new { ti.Ordinal }, relationships: ti => new { ti.Owner }); - var todoItem = new TodoItem + var existingOwner = _personFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingOwner); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new { - Ordinal = 123, - Description = "some" + data = new + { + type = "todoItems", + attributes = new + { + ordinal = 123, + description = "some" + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingOwner.StringId + } + } + } + } }; - var owner = new Person { FirstName = "Alice", LastName = "Cooper" }; - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - todoItem.Owner = owner; + + var route = "/api/v1/todoItems?include=owner&fields=ordinal&fields[owner]=firstName"; // Act - var (body, response) = await Post("/api/v1/todoItems?include=owner&fields=ordinal&fields[owner]=firstName", serializer.Serialize(todoItem)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - Assert.NotNull(responseItem); - Assert.Equal(todoItem.Ordinal, responseItem.Ordinal); - Assert.Null(responseItem.Description); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["ordinal"].Should().Be(123); + responseDocument.SingleData.Attributes.Should().NotContainKey("description"); - Assert.NotNull(responseItem.Owner); - Assert.Equal(owner.FirstName, responseItem.Owner.FirstName); - Assert.Null(responseItem.Owner.LastName); + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(existingOwner.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingOwner.FirstName); + responseDocument.Included[0].Attributes.Should().NotContainKey("lastName"); } [Fact] public async Task CreateWithRelationship_HasOneFromIndependentSide_IsCreated() { // Arrange - var serializer = GetSerializer(pr => new { }, pr => new { pr.Person }); - var person = new Person(); - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - var personRole = new PersonRole { Person = person }; - var requestBody = serializer.Serialize(personRole); + var existingPerson = new Person(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "personRoles", + relationships = new + { + person = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + var route = "/api/v1/personRoles"; // Act - var (body, response) = await Post("/api/v1/personRoles", requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var personRoleResult = _dbContext.PersonRoles.AsNoTracking() - .Include(c => c.Person) - .SingleOrDefault(c => c.Id == responseItem.Id); - Assert.NotEqual(0, responseItem.Id); - Assert.Equal(person.Id, personRoleResult.Person.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newPersonRoleId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personRoleInDatabase = await dbContext.PersonRoles + .Include(role => role.Person) + .Where(role => role.Id == int.Parse(newPersonRoleId)) + .SingleAsync(); + + personRoleInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); } [Fact] public async Task CreateResource_SimpleResource_HeaderLocationsAreCorrect() { // Arrange - var serializer = GetSerializer(ti => new { ti.CreatedDate, ti.Description, ti.Ordinal }); var todoItem = _todoItemFaker.Generate(); + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + createdDate = todoItem.CreatedDate, + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + + var route = "/api/v1/todoItems"; + // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); - var responseItem = _deserializer.DeserializeSingle(body).Data; + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - Assert.Equal($"/api/v1/todoItems/{responseItem.Id}", response.Headers.Location.ToString()); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newTodoItemId = responseDocument.SingleData.Id; + httpResponse.Headers.Location.Should().Be("/api/v1/todoItems/" + newTodoItemId); } [Fact] public async Task CreateResource_UnknownResourceType_Fails() { // Arrange - string content = JsonConvert.SerializeObject(new + var requestBody = new { data = new { type = "something" } - }); + }; + + var route = "/api/v1/todoItems"; // Act - var (body, response) = await Post("/api/v1/todoItems", content); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); - Assert.Equal("Failed to deserialize request body: Payload includes unknown resource type.", errorDocument.Errors[0].Title); - Assert.StartsWith("The resource 'something' is not registered on the resource graph.", errorDocument.Errors[0].Detail); - Assert.Contains("Request body: <<", errorDocument.Errors[0].Detail); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("The resource 'something' is not registered on the resource graph."); + responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); } [Fact] public async Task CreateResource_Blocked_Fails() { // Arrange - var content = new + var requestBody = new { data = new { type = "todoItems", attributes = new Dictionary { - { "alwaysChangingValue", "X" } + ["alwaysChangingValue"] = "X" } } }; - var requestBody = JsonConvert.SerializeObject(content); + var route = "/api/v1/todoItems"; // Act - var (body, response) = await Post("/api/v1/todoItems", requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - - var error = errorDocument.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); - Assert.Equal("Failed to deserialize request body: Assigning to the requested attribute is not allowed.", error.Title); - Assert.StartsWith("Assigning to 'alwaysChangingValue' is not allowed. - Request body:", error.Detail); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Assigning to the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Assigning to 'alwaysChangingValue' is not allowed. - Request body:"); } [Fact] public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() { // Arrange - var serializer = GetSerializer(e => new { e.FirstName }, e => new { e.Passport }); - var passport = new Passport(_dbContext); - var currentPerson = _personFaker.Generate(); - currentPerson.Passport = passport; - _dbContext.People.Add(currentPerson); - await _dbContext.SaveChangesAsync(); - var newPerson = _personFaker.Generate(); - newPerson.Passport = passport; + var existingPerson = _personFaker.Generate(); + + Passport passport = null; + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + + passport = new Passport(dbContext); + existingPerson.Passport = passport; + + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + relationships = new + { + passport = new + { + data = new + { + type = "passports", + id = passport.StringId + } + } + } + } + }; + var route = "/api/v1/people"; + // Act - var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var newPersonDb = _dbContext.People.AsNoTracking().Where(p => p.Id == responseItem.Id).Include(e => e.Passport).Single(); - Assert.NotNull(newPersonDb.Passport); - Assert.Equal(passport.Id, newPersonDb.Passport.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newPersonId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personsInDatabase = await dbContext.People + .Include(p => p.Passport) + .ToListAsync(); + + var existingPersonInDatabase = personsInDatabase.Single(p => p.Id == existingPerson.Id); + existingPersonInDatabase.Passport.Should().BeNull(); + + var newPersonInDatabase = personsInDatabase.Single(p => p.StringId == newPersonId); + newPersonInDatabase.Passport.Id.Should().Be(passport.Id); + }); } [Fact] public async Task CreateRelationship_ToManyWithImplicitRemove_IsCreated() { // Arrange - var serializer = GetSerializer(e => new { e.FirstName }, e => new { e.TodoItems }); - var currentPerson = _personFaker.Generate(); var todoItems = _todoItemFaker.Generate(3); - currentPerson.TodoItems = todoItems.ToHashSet(); - _dbContext.Add(currentPerson); - await _dbContext.SaveChangesAsync(); - var firstTd = todoItems[0]; - var secondTd = todoItems[1]; - var thirdTd = todoItems[2]; - var newPerson = _personFaker.Generate(); - newPerson.TodoItems = new HashSet { firstTd, secondTd }; + var existingPerson = _personFaker.Generate(); + existingPerson.TodoItems = todoItems.ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + relationships = new + { + todoItems = new + { + data = new[] + { + new + { + type = "todoItems", + id = todoItems[0].StringId + }, + new + { + type = "todoItems", + id = todoItems[1].StringId + } + } + } + } + } + }; + + var route = "/api/v1/people"; // Act - var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var newPersonDb = _dbContext.People.AsNoTracking().Where(p => p.Id == responseItem.Id).Include(e => e.TodoItems).Single(); - var oldPersonDb = _dbContext.People.AsNoTracking().Where(p => p.Id == currentPerson.Id).Include(e => e.TodoItems).Single(); - Assert.Equal(2, newPersonDb.TodoItems.Count); - Assert.Single(oldPersonDb.TodoItems); - Assert.NotNull(newPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == firstTd.Id)); - Assert.NotNull(newPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == secondTd.Id)); - Assert.NotNull(oldPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == thirdTd.Id)); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newPersonId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personsInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .ToListAsync(); + + var existingPersonInDatabase = personsInDatabase.Single(p => p.Id == existingPerson.Id); + existingPersonInDatabase.TodoItems.Should().HaveCount(1); + existingPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[2].Id); + + var newPersonInDatabase = personsInDatabase.Single(p => p.StringId == newPersonId); + newPersonInDatabase.TodoItems.Should().HaveCount(2); + newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[0].Id); + newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[1].Id); + }); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 34d6d18387..fe90b567b6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -1,19 +1,14 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; using FluentAssertions; -using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Internal; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; From fe0e46309e8f7bb19b51e64e3dda5a53b154a420 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 02:07:17 +0200 Subject: [PATCH 070/240] various comments --- .../Repositories/DbContextExtensions.cs | 6 +- .../EntityFrameworkCoreRepository.cs | 213 +++++++----------- src/JsonApiDotNetCore/TypeHelper.cs | 2 + 3 files changed, 90 insertions(+), 131 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 3642094984..48e51c48dc 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCore.Repositories { public static class DbContextExtensions { - internal static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); var entityType = identifiable.GetType(); @@ -23,6 +24,9 @@ internal static object GetTrackedIdentifiable(this DbContext dbContext, IIdentif public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + var trackedIdentifiable = (IIdentifiable)dbContext.GetTrackedIdentifiable(resource); if (trackedIdentifiable == null) { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index c2af97e25a..918ea6ec89 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -13,7 +13,6 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -137,7 +136,6 @@ public virtual async Task CreateAsync(TResource resource) } _dbContext.Set().Add(resource); - await SaveChangesAsync(); FlushFromCache(resource); @@ -151,12 +149,11 @@ public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection() + primaryResource = await _dbContext.Set() .Include(relationship.Property.Name) - .Where(r => r.Id.Equals(id)) + .Where(resource => resource.Id.Equals(id)) .FirstOrDefaultAsync(); - if (primaryResourceFromDatabase == null) + if (primaryResource == null) { + var tempResource = CreatePrimaryResourceWithAssignedId(id); var resourceContext = _resourceGraph.GetResourceContext(); - throw new ResourceNotFoundException(primaryResource.StringId, resourceContext.PublicName); + throw new ResourceNotFoundException(tempResource.StringId, resourceContext.PublicName); } - - primaryResource = primaryResourceFromDatabase; } else { + primaryResource = CreatePrimaryResourceWithAssignedId(id); primaryResource = (TResource) _dbContext.GetTrackedOrAttach(primaryResource); - await LoadRelationship(primaryResource, relationship); + + await LoadRelationship(relationship, primaryResource); } - - await AssignValueToRelationship(relationship, primaryResource, secondaryResourceIds); + await AssignValueToRelationship(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } @@ -199,34 +196,37 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r _traceWriter.LogMethodStart(new {resourceFromRequest, resourceFromDatabase}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); - + + // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { - if (relationship is HasOneAttribute hasOneRelationship && - HasForeignKeyAtLeftSideOfHasOneRelationship(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSideOfHasOneRelationship(hasOneRelationship)) { FlushFromCache(resourceFromDatabase); resourceFromDatabase = await _dbContext.Set() + // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? .Include(relationship.Property.Name) - .Where(r => r.Id.Equals(resourceFromRequest.Id)) + .Where(resource => resource.Id.Equals(resourceFromRequest.Id)) .FirstAsync(); } else { - // A database entity might not be tracked if it was retrieved through projection. + // TODO: I believe the comment below does not apply here (anymore). The calling resource service always fetches the entire record. + // And commenting out the line below still keeps all tests green. + // Does this comment maybe apply to SetRelationshipAsync()? + + // A database entity might not be tracked if it was retrieved through projection. resourceFromDatabase = (TResource) _dbContext.GetTrackedOrAttach(resourceFromDatabase); - // Ensures complete replacements of relationships. - await LoadRelationship(resourceFromDatabase, relationship); + // Ensures complete replacement of the relationship. + await LoadRelationship(relationship, resourceFromDatabase); } var relationshipAssignment = relationship.GetValue(resourceFromRequest); await AssignValueToRelationship(relationship, resourceFromDatabase, relationshipAssignment); - - //_dbContext.Entry(resourceFromDatabase).State = EntityState.Modified; } - + foreach (var attribute in _targetedFields.Attributes) { attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); @@ -240,7 +240,7 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - var resource = _dbContext.GetTrackedOrAttach(CreateInstanceWithAssignedId(id)); + var resource = _dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); _dbContext.Remove(resource); await SaveChangesAsync(); @@ -250,23 +250,24 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - + var relationship = _targetedFields.Relationships.Single(); - var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreateInstanceWithAssignedId(id)); - - await LoadRelationship(primaryResource, relationship); + var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - var currentRelationshipAssignment = (IReadOnlyCollection)relationship.GetValue(primaryResource); - var newRelationshipAssignment = currentRelationshipAssignment.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToArray(); - - if (newRelationshipAssignment.Length < currentRelationshipAssignment.Count) + await LoadRelationship(relationship, primaryResource); + + var currentRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); + var newRightResources = currentRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToArray(); + + // TODO: What does this < comparison mean? + if (newRightResources.Length < currentRightResources.Count) { - await AssignValueToRelationship(relationship, primaryResource, newRelationshipAssignment); + await AssignValueToRelationship(relationship, primaryResource, newRightResources); await SaveChangesAsync(); } } - private TResource CreateInstanceWithAssignedId(TId id) + private TResource CreatePrimaryResourceWithAssignedId(TId id) { var resource = _resourceFactory.CreateInstance(); resource.Id = id; @@ -300,6 +301,8 @@ private void DetachRelationships(TResource resource) // trigger a full reload of relationships: the navigation // property actually needs to be nulled out, otherwise // EF Core will still add duplicate instances to the collection. + + // TODO: Ensure that a test exists for this. Commenting out the next line still makes all tests succeed. relationship.SetValue(resource, null, _resourceFactory); } else if (rightValue != null) @@ -310,120 +313,84 @@ private void DetachRelationships(TResource resource) } /// - /// Before assigning new relationship values (UpdateAsync), we need to - /// attach the current database values of the relationship to the dbContext, else + /// Before assigning new relationship values (), we need to + /// attach the current database values of the relationship to the DbContext, else /// it will not perform a complete-replace which is required for /// one-to-many and many-to-many. - /// + /// /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. /// If we want to update this todo-item set to `t3` and `t4`, simply assigning /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, - /// after which the reassignment `p1.todoItems = [t3, t4]` will actually + /// after which the reassignment `p1.todoItems = [t3, t4]` will actually /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. + /// /// - protected async Task LoadRelationship(TResource resource, RelationshipAttribute relationship) + protected async Task LoadRelationship(RelationshipAttribute relationship, TResource resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - - var entityEntry = _dbContext.Entry(resource); - NavigationEntry navigationEntry = null; - - if (relationship is HasManyThroughAttribute hasManyThroughRelationship) - { - navigationEntry = entityEntry.Collection(hasManyThroughRelationship.ThroughProperty.Name); - } - else if (relationship is HasManyAttribute hasManyRelationship) - { - navigationEntry = entityEntry.Collection(hasManyRelationship.Property.Name); - } - else if (relationship is HasOneAttribute hasOneRelationship) - { - navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); - - /*var foreignKey = GetForeignKeyAtSideOfHasOneRelationship(hasOneRelationship); - if (foreignKey == null || foreignKey.Properties.Count != 1) - { - // If the primary resource is the dependent side of a to-one relationship, there can be no FK - // violations resulting from the implicit removal. - navigationEntry = entityEntry.Reference(hasOneRelationship.Property.Name); - }*/ - } + var navigationEntry = GetNavigationEntry(relationship, resource); if (navigationEntry != null) { await navigationEntry.LoadAsync(); } } - - /// - /// Loads the inverse relationships to prevent foreign key constraints from being violated - /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// - private async Task LoadInverseRelationships(RelationshipAttribute relationship, object resource) + + // TODO: Rename this method to something better. + private NavigationEntry GetNavigationEntry(RelationshipAttribute relationship, TResource resource) { - if (relationship.InverseNavigationProperty != null) + EntityEntry entityEntry = _dbContext.Entry(resource); + + switch (relationship) { - if (relationship is HasOneAttribute hasOneRelationship && IsOneToOne(hasOneRelationship) == true) + case HasManyThroughAttribute hasManyThroughRelationship: { - var entityEntry = _dbContext.Entry(resource); - await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); + return entityEntry.Collection(hasManyThroughRelationship.ThroughProperty.Name); + } + case HasManyAttribute hasManyRelationship: + { + return entityEntry.Collection(hasManyRelationship.Property.Name); + } + case HasOneAttribute hasOneRelationship: + { + return entityEntry.Reference(hasOneRelationship.Property.Name); } } + + return null; } - private bool? IsOneToOne(HasOneAttribute hasOneRelationship) + /// + /// Loads the inverse of a one-to-one relationship, to support an implicit remove. This prevents a foreign key constraint from being violated. + /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + /// + private async Task LoadInverseForOneToOneRelationship(RelationshipAttribute relationship, object resource) { - if (hasOneRelationship.InverseNavigationProperty != null) + if (relationship.InverseNavigationProperty != null && relationship is HasOneAttribute hasOneRelationship) { - var inversePropertyIsCollection = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType) != null; - return !inversePropertyIsCollection; - } + var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); + var isOneToOneRelationship = elementType == null; - return null; + if (isOneToOneRelationship) + { + var entityEntry = _dbContext.Entry(resource); + await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); + } + } } private async Task AssignValueToRelationship(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { // Ensures the new relationship assignment will not result in entities being tracked more than once. - object trackedValueToAssign = null; - - if (valueToAssign != null) + var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + if (trackedValueToAssign != null) { - trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - - // Ensures successful handling of implicit removals of relationships. - await LoadInverseRelationships(relationship, trackedValueToAssign); + await LoadInverseForOneToOneRelationship(relationship, trackedValueToAssign); } - if (relationship is HasOneAttribute hasOneRelationship) - { - var rightResourceId = trackedValueToAssign is IIdentifiable rightResource - ? rightResource.GetTypedId() - : null; - - // https://docs.microsoft.com/en-us/ef/core/saving/related-data - /* - var foreignKey = GetForeignKeyAtSideOfHasOneRelationship(hasOneRelationship); - if (foreignKey != null) - { - foreach (var foreignKeyProperty in foreignKey.Properties) - { - if (foreignKeyProperty.IsShadowProperty()) - { - _dbContext.Entry(leftResource).Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; - } - else - { - foreignKeyProperty.PropertyInfo.SetValue(leftResource, rightResourceId); - _dbContext.Entry(leftResource).State = EntityState.Modified; - } - } - }*/ - } - relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } @@ -442,19 +409,14 @@ private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Ty return null; } - private object EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollection rightResources, Type rightCollectionType) + private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollection rightResources, Type rightCollectionType) { var rightResourcesTracked = new object[rightResources.Count]; int index = 0; foreach (var rightResource in rightResources) { - var trackedIdentifiable = _dbContext.GetTrackedOrAttach(rightResource); - - // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. - var identifiableRuntimeType = trackedIdentifiable.GetType(); - rightResourcesTracked[index] = Convert.ChangeType(trackedIdentifiable, identifiableRuntimeType); - + rightResourcesTracked[index] = _dbContext.GetTrackedOrAttach(rightResource); index++; } @@ -469,15 +431,6 @@ private bool HasForeignKeyAtLeftSideOfHasOneRelationship(HasOneAttribute relatio return navigation.ForeignKey.DeclaringEntityType.ClrType == typeof(TResource); } - private IForeignKey GetForeignKeyAtSideOfHasOneRelationship(HasOneAttribute relationship) - { - var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); - var navigation = entityType.FindNavigation(relationship.Property.Name); - - var isForeignKeyAtRelationshipSide = navigation.ForeignKey.DeclaringEntityType.ClrType == typeof(TResource); - return isForeignKeyAtRelationshipSide ? navigation.ForeignKey : null; - } - private async Task SaveChangesAsync() { try diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index bf72b13ce7..8e457a9296 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -372,6 +372,8 @@ internal static ConstructorInfo GetLongestConstructor(ConstructorInfo[] construc internal static bool ConstructorDependsOnDbContext(Type resourceType) { + // TODO: Rewrite existing test(s) so this can be reverted. + var constructors = resourceType.GetConstructors().Where(c => !c.IsStatic).ToArray(); if (constructors.Any()) { From 7705a3835a5105ac23bf97b5e11f045038a913d9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 10:30:18 +0200 Subject: [PATCH 071/240] added tests to ModelStateValidationTests for new endpoints --- .../EntityFrameworkCoreRepository.cs | 9 +- .../ModelStateValidationTests.cs | 133 +++++++++++++----- 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 918ea6ec89..b5ff5931d9 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -313,13 +313,12 @@ private void DetachRelationships(TResource resource) } /// - /// Before assigning new relationship values (), we need to - /// attach the current database values of the relationship to the DbContext, else - /// it will not perform a complete-replace which is required for - /// one-to-many and many-to-many. + /// Before assigning new relationship values, we need to attach the current database values + /// of the relationship to the DbContext, otherwise it will not perform a complete-replace, + /// which is required for one-to-many and many-to-many. /// /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. - /// If we want to update this todo-item set to `t3` and `t4`, simply assigning + /// If we want to update this set to `t3` and `t4`, simply assigning /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, /// after which the reassignment `p1.todoItems = [t3, t4]` will actually diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 5f2d74715a..361788d5be 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -21,7 +21,7 @@ public ModelStateValidationTests(IntegrationTestContext await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -265,7 +260,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -279,6 +273,51 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); } + [Fact] + public async Task When_posting_annotated_to_many_relationship_it_must_succeed() + { + // Arrange + var directory = new SystemDirectory + { + Name="Projects", + IsCaseSensitive = true + }; + + var file = new SystemFile + { + FileName = "Main.cs", + SizeInBytes = 100 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(directory, file); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "systemFiles", + id = file.StringId + } + } + }; + + string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + [Fact] public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() { @@ -295,7 +334,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -308,7 +347,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -336,7 +374,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -349,7 +387,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -381,7 +418,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -394,7 +431,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -426,7 +462,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -453,7 +489,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/-1"; // Act @@ -491,7 +526,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -504,7 +539,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -571,7 +605,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -617,7 +651,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -645,7 +678,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -677,7 +710,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -705,7 +737,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -732,7 +764,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -771,7 +802,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -780,7 +811,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId + "/relationships/parent"; // Act @@ -826,7 +856,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new[] { @@ -838,7 +868,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId + "/relationships/files"; // Act @@ -849,5 +878,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.Should().BeNull(); } + + [Fact] + public async Task When_deleting_annotated_to_many_relationship_it_must_succeed() + { + // Arrange + var directory = new SystemDirectory + { + Name="Projects", + IsCaseSensitive = true, + Files = new List + { + new SystemFile + { + FileName = "Main.cs", + SizeInBytes = 100 + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(directory); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } } } From 1fa1287d9b70c333e3d756026e27cface2ce84c8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 10:33:53 +0200 Subject: [PATCH 072/240] more cleanup --- .../Meta/{ResourceTests.cs => ResourceMetaTests.cs} | 4 ++-- .../ModelStateValidation/NoModelStateValidationTests.cs | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/{ResourceTests.cs => ResourceMetaTests.cs} (93%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs similarity index 93% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 60690f1758..620f5e604b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResourceTests : IClassFixture> + public sealed class ResourceMetaTests : IClassFixture> { private readonly IntegrationTestContext _testContext; - public ResourceTests(IntegrationTestContext testContext) + public ResourceMetaTests(IntegrationTestContext testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 86a22b14dc..5fa2e6d2f7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -21,7 +21,7 @@ public NoModelStateValidationTests(IntegrationTestContext await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -76,7 +75,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act From 994ed0588281c7b5088ae2f9d72059e367f89538 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 16 Oct 2020 10:34:51 +0200 Subject: [PATCH 073/240] test: skip injectable resource test until efcore 5 lands --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 7 ++----- .../Acceptance/InjectableResourceTests.cs | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 7419d568dc..6cbb9c6b15 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -390,11 +390,8 @@ private async Task GetPrimaryResourceById(TId id, TopFieldSelection f if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - if (!TypeHelper.ConstructorDependsOnDbContext(_request.PrimaryResource.ResourceType)) - { - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - primaryLayer.Projection = new Dictionary {{idAttribute, null}}; - } + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + primaryLayer.Projection = new Dictionary {{idAttribute, null}}; } else if (fieldSelection == TopFieldSelection.AllAttributes && primaryLayer.Projection != null) { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index 4d6dc63aaa..6a800f6592 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -196,7 +196,7 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() resource => resource.Attributes.ContainsKey("lastName")); } - [Fact] + [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] public async Task Fail_When_Deleting_Missing_Passport() { // Arrange From f26e88a1d50695e7d373d2ebbbbebb6bc1c5e078 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 10:56:33 +0200 Subject: [PATCH 074/240] Merged tests --- .../Acceptance/Spec/CreatingDataTests.cs | 106 ++++------------ .../Spec/UpdatingRelationshipsTests.cs | 118 +++++------------- 2 files changed, 53 insertions(+), 171 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index fe8dbb7da7..e4e259206f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -139,11 +139,14 @@ public async Task ClientGeneratedId_IntegerIdAndNotEnabled_IsForbidden() public async Task CreateWithRelationship_HasMany_IsCreated() { // Arrange - var existingTodoItem = _todoItemFaker.Generate(); + var todoItems = _todoItemFaker.Generate(3); + + var existingPerson = _personFaker.Generate(); + existingPerson.TodoItems = todoItems.ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.TodoItems.Add(existingTodoItem); + dbContext.People.Add(existingPerson); await dbContext.SaveChangesAsync(); }); @@ -151,7 +154,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "todoCollections", + type = "people", relationships = new { todoItems = new @@ -161,7 +164,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "todoItems", - id = existingTodoItem.StringId + id = todoItems[0].StringId + }, + new + { + type = "todoItems", + id = todoItems[1].StringId } } } @@ -169,7 +177,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/todoCollections"; + var route = "/api/v1/people"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -177,17 +185,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - var newTodoCollectionId = responseDocument.SingleData.Id; + var newPersonId = responseDocument.SingleData.Id; await _testContext.RunOnDatabaseAsync(async dbContext => { - var todoCollectionsInDatabase = await dbContext.TodoItemCollections - .Include(collection => collection.TodoItems) + var personsInDatabase = await dbContext.People + .Include(p => p.TodoItems) .ToListAsync(); - var newTodoCollectionInDatabase = todoCollectionsInDatabase.Single(c => c.StringId == newTodoCollectionId); - newTodoCollectionInDatabase.TodoItems.Should().HaveCount(1); - newTodoCollectionInDatabase.TodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + var existingPersonInDatabase = personsInDatabase.Single(p => p.Id == existingPerson.Id); + existingPersonInDatabase.TodoItems.Should().HaveCount(1); + existingPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[2].Id); + + var newPersonInDatabase = personsInDatabase.Single(p => p.StringId == newPersonId); + newPersonInDatabase.TodoItems.Should().HaveCount(2); + newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[0].Id); + newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[1].Id); }); } @@ -609,7 +622,7 @@ public async Task CreateResource_Blocked_Fails() } [Fact] - public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() + public async Task CreateRelationship_OneToOneWithImplicitRemove_IsCreated() { // Arrange var existingPerson = _personFaker.Generate(); @@ -668,74 +681,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => newPersonInDatabase.Passport.Id.Should().Be(passport.Id); }); } - - [Fact] - public async Task CreateRelationship_ToManyWithImplicitRemove_IsCreated() - { - // Arrange - var todoItems = _todoItemFaker.Generate(3); - - var existingPerson = _personFaker.Generate(); - existingPerson.TodoItems = todoItems.ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingPerson); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - relationships = new - { - todoItems = new - { - data = new[] - { - new - { - type = "todoItems", - id = todoItems[0].StringId - }, - new - { - type = "todoItems", - id = todoItems[1].StringId - } - } - } - } - } - }; - - var route = "/api/v1/people"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - var newPersonId = responseDocument.SingleData.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personsInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .ToListAsync(); - - var existingPersonInDatabase = personsInDatabase.Single(p => p.Id == existingPerson.Id); - existingPersonInDatabase.TodoItems.Should().HaveCount(1); - existingPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[2].Id); - - var newPersonInDatabase = personsInDatabase.Single(p => p.StringId == newPersonId); - newPersonInDatabase.TodoItems.Should().HaveCount(2); - newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[0].Id); - newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[1].Id); - }); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index fe90b567b6..7798863d08 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -254,22 +254,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() { // Arrange + var person1 = _personFaker.Generate(); + person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - var todoCollection = new TodoItemCollection - { - Owner = _personFaker.Generate(), - TodoItems = new HashSet - { - _todoItemFaker.Generate() - } - }; + var person2 = _personFaker.Generate(); + person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - var newTodoItem1 = _todoItemFaker.Generate(); - var newTodoItem2 = _todoItemFaker.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(todoCollection, newTodoItem1, newTodoItem2); + dbContext.People.AddRange(person1, person2); await dbContext.SaveChangesAsync(); }); @@ -277,23 +270,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "todoCollections", - id = todoCollection.StringId, + type = "people", + id = person2.StringId, relationships = new Dictionary { ["todoItems"] = new { data = new[] { - new {type = "todoItems", id = newTodoItem1.StringId}, - new {type = "todoItems", id = newTodoItem2.StringId} + new + { + type = "todoItems", + id = person1.TodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = person1.TodoItems.ElementAt(1).StringId + } } } } } }; - var route = "/api/v1/todoCollections/" + todoCollection.StringId; + var route = "/api/v1/people/" + person2.StringId; // Act var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -303,12 +304,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var todoCollectionInDatabase = await dbContext.TodoItemCollections - .Include(collection => collection.TodoItems) - .Where(collection => collection.Id == todoCollection.Id) - .FirstAsync(); + var personsInDatabase = await dbContext.People + .Include(person => person.TodoItems) + .ToListAsync(); - todoCollectionInDatabase.TodoItems.Should().HaveCount(2); + personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().HaveCount(1); + + var person2InDatabase = personsInDatabase.Single(person => person.Id == person2.Id); + person2InDatabase.TodoItems.Should().HaveCount(2); + person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(0).Id); + person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(1).Id); }); } @@ -474,7 +479,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Updating_ToOne_Relationship_With_Implicit_Remove() + public async Task Updating_OneToOne_Relationship_With_Implicit_Remove() { // Arrange var person1 = _personFaker.Generate(); @@ -527,73 +532,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Updating_ToMany_Relationship_With_Implicit_Remove() - { - // Arrange - var person1 = _personFaker.Generate(); - person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - var person2 = _personFaker.Generate(); - person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.AddRange(person1, person2); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person2.StringId, - relationships = new Dictionary - { - ["todoItems"] = new - { - data = new[] - { - new - { - type = "todoItems", - id = person1.TodoItems.ElementAt(0).StringId - }, - new - { - type = "todoItems", - id = person1.TodoItems.ElementAt(1).StringId - } - } - } - } - } - }; - - var route = "/api/v1/people/" + person2.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personsInDatabase = await dbContext.People - .Include(person => person.TodoItems) - .ToListAsync(); - - personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().HaveCount(1); - - var person2InDatabase = personsInDatabase.Single(person => person.Id == person2.Id); - person2InDatabase.TodoItems.Should().HaveCount(2); - person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(0).Id); - person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(1).Id); - }); - } - [Fact] public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() { From d3e2174122364668f5567fa9b083eedbb718f56d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 12:55:55 +0200 Subject: [PATCH 075/240] move/merge tests --- .../Spec/UpdatingRelationshipsTests.cs | 148 +++++++++--------- .../ResourceInheritance/InheritanceTests.cs | 2 +- .../RelationshipDictionaryTests.cs | 14 +- .../IncludedResourceObjectBuilderTests.cs | 2 +- 4 files changed, 83 insertions(+), 83 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 7798863d08..4685c35ece 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -35,7 +35,7 @@ public UpdatingRelationshipsTests(IntegrationTestContext [Fact] public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() { - // Arrange + // Arrange var todoItem = _todoItemFaker.Generate(); var otherTodoItem = _todoItemFaker.Generate(); @@ -89,7 +89,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() { - // Arrange + // Arrange var todoItem = _todoItemFaker.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -136,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patching_Resource() { - // Arrange + // Arrange var todoItem = _todoItemFaker.Generate(); var otherTodoItem = _todoItemFaker.Generate(); @@ -188,67 +188,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => todoItemInDatabase.ParentTodoId.Should().Be(todoItem.Id); }); } - - [Fact] - public async Task Fails_When_Patching_Primary_Endpoint_With_Missing_Secondary_Resources() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(todoItem, person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - relationships = new Dictionary - { - ["stakeHolders"] = new - { - data = new[] - { - new {type = "people", id = person.StringId}, - new {type = "people", id = "900000"}, - new {type = "people", id = "900001"} - } - }, - ["parentTodo"] = new - { - data = new {type = "todoItems", id = "900002"} - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(3); - - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '900000' being assigned to relationship 'stakeHolders' does not exist."); - - responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[1].Detail.Should().Be("Resource of type 'people' with ID '900001' being assigned to relationship 'stakeHolders' does not exist."); - - responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[2].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[2].Detail.Should().Be("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist."); - } [Fact] public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() @@ -752,15 +691,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Fails_When_Patching_Relationships_Endpoint_With_Unknown_Relationship() + public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { // Arrange - var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); + var person = _personFaker.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(person, todoItem); + dbContext.AddRange(todoItem, person); await dbContext.SaveChangesAsync(); }); @@ -768,11 +707,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "people", id = person.StringId + type = "todoItems", + id = todoItem.StringId, + relationships = new Dictionary + { + ["stakeHolders"] = new + { + data = new[] + { + new {type = "people", id = person.StringId}, + new {type = "people", id = "900000"}, + new {type = "people", id = "900001"} + } + }, + ["parentTodo"] = new + { + data = new {type = "todoItems", id = "900002"} + } + } } }; - var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/invalid"; + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -780,14 +736,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.Should().HaveCount(3); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'invalid'."); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '900000' being assigned to relationship 'stakeHolders' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'people' with ID '900001' being assigned to relationship 'stakeHolders' does not exist."); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[2].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[2].Detail.Should().Be("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist."); } [Fact] - public async Task Fails_When_Patching_Relationships_Endpoint_With_Missing_Primary_Resource() + public async Task Fails_When_Patching_On_Relationships_Endpoint_With_Missing_Primary_Resource() { // Arrange var person = _personFaker.Generate(); @@ -820,7 +785,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' with ID '99999999' does not exist."); } - [Fact] + [Fact] + public async Task Fails_When_Patching_On_Relationships_Endpoint_With_Unknown_Relationship() + { + // Arrange + var person = _personFaker.Generate(); + var todoItem = _todoItemFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, todoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", id = person.StringId + } + }; + + var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/invalid"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'invalid'."); + } + + [Fact] public async Task Fails_When_Posting_To_Many_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resources() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index eebbb3ede2..04ece493df 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -161,7 +161,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_patch_resource_with_to_many_relationship_through_relationship_link() { - // Arrange + // Arrange var child = new Man(); var father = new Man(); var mother = new Woman(); diff --git a/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs index 9873f7f4fb..f431a8a604 100644 --- a/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs +++ b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs @@ -69,7 +69,7 @@ public RelationshipDictionaryTests() [Fact] public void RelationshipsDictionary_GetByRelationships() { - // Arrange + // Arrange RelationshipsDictionary relationshipsDictionary = new RelationshipsDictionary(Relationships); // Act @@ -84,7 +84,7 @@ public void RelationshipsDictionary_GetByRelationships() [Fact] public void RelationshipsDictionary_GetAffected() { - // Arrange + // Arrange RelationshipsDictionary relationshipsDictionary = new RelationshipsDictionary(Relationships); // Act @@ -101,7 +101,7 @@ public void RelationshipsDictionary_GetAffected() [Fact] public void ResourceHashSet_GetByRelationships() { - // Arrange + // Arrange ResourceHashSet resources = new ResourceHashSet(AllResources, Relationships); // Act @@ -122,7 +122,7 @@ public void ResourceHashSet_GetByRelationships() [Fact] public void ResourceDiff_GetByRelationships() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id }).ToList()); DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); @@ -155,7 +155,7 @@ public void ResourceDiff_GetByRelationships() [Fact] public void ResourceDiff_Loops_Over_Diffs() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); @@ -172,7 +172,7 @@ public void ResourceDiff_Loops_Over_Diffs() [Fact] public void ResourceDiff_GetAffected_Relationships() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); @@ -190,7 +190,7 @@ public void ResourceDiff_GetAffected_Relationships() [Fact] public void ResourceDiff_GetAffected_Attributes() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); var updatedAttributes = new Dictionary> { diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs index e6f43b0919..11100d3c01 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -14,7 +14,7 @@ public sealed class IncludedResourceObjectBuilderTests : SerializerTestsSetup [Fact] public void BuildIncluded_DeeplyNestedCircularChainOfSingleData_CanBuild() { - // Arrange + // Arrange var (article, author, _, reviewer, _) = GetAuthorChainInstances(); var authorChain = GetIncludedRelationshipsChain("author.blogs.reviewer.favoriteFood"); var builder = GetBuilder(); From e5714067bc7f50eff79ccc2bde5f208274a6ba41 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 17:11:06 +0200 Subject: [PATCH 076/240] isolated tests with composite primary key --- .../Data/AppDbContext.cs | 10 - .../Repositories/CarRepository.cs | 124 ------- .../Acceptance/Spec/UpdatingDataTests.cs | 150 -------- .../IntegrationTests/CompositeKeys}/Car.cs | 2 +- .../CompositeKeys/CarExpressionRewriter.cs | 169 +++++++++ .../CompositeKeys/CarRepository.cs | 54 +++ .../CompositeKeys}/CarsController.cs | 10 +- .../CompositeKeys/CompositeDbContext.cs | 26 ++ .../CompositeKeys/CompositeKeyTests.cs | 328 ++++++++++++++++++ .../IntegrationTests/CompositeKeys}/Engine.cs | 2 +- .../CompositeKeys}/EnginesController.cs | 10 +- 11 files changed, 587 insertions(+), 298 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs rename {src/Examples/JsonApiDotNetCoreExample/Models => test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys}/Car.cs (93%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs rename {src/Examples/JsonApiDotNetCoreExample/Controllers => test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys}/CarsController.cs (62%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs rename {src/Examples/JsonApiDotNetCoreExample/Models => test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys}/Engine.cs (78%) rename {src/Examples/JsonApiDotNetCoreExample/Controllers => test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys}/EnginesController.cs (62%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 9e3c6b256a..fcfe431211 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -22,8 +22,6 @@ public sealed class AppDbContext : DbContext public DbSet ArticleTags { get; set; } public DbSet Tags { get; set; } public DbSet Blogs { get; set; } - public DbSet Cars { get; set; } - public DbSet Engines { get; set; } public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) { @@ -94,14 +92,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasOne(p => p.OneToOneTodoItem) .WithOne(p => p.OneToOnePerson) .HasForeignKey(p => p.OneToOnePersonId); - - modelBuilder.Entity() - .HasKey(c => new { c.RegionId, c.LicensePlate }); - - modelBuilder.Entity() - .HasOne(e => e.Car) - .WithOne(c => c.Engine) - .HasForeignKey(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs b/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs deleted file mode 100644 index e9c56e8b88..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Repositories/CarRepository.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Repositories -{ - public sealed class CarRepository : EntityFrameworkCoreRepository - { - private readonly IResourceGraph _resourceGraph; - - public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, - IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - _resourceGraph = resourceGraph; - } - - protected override IQueryable ApplyQueryLayer(QueryLayer layer) - { - RecursiveRewriteFilterInLayer(layer); - - return base.ApplyQueryLayer(layer); - } - - private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) - { - if (queryLayer.Filter != null) - { - var writer = new CarFilterRewriter(_resourceGraph); - queryLayer.Filter = (FilterExpression) writer.Visit(queryLayer.Filter, null); - } - - if (queryLayer.Projection != null) - { - foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) - { - RecursiveRewriteFilterInLayer(nextLayer); - } - } - } - - private sealed class CarFilterRewriter : QueryExpressionRewriter - { - private readonly AttrAttribute _regionIdAttribute; - private readonly AttrAttribute _licensePlateAttribute; - - public CarFilterRewriter(IResourceContextProvider resourceContextProvider) - { - var carResourceContext = resourceContextProvider.GetResourceContext(); - - _regionIdAttribute = - carResourceContext.Attributes.Single(attribute => - attribute.Property.Name == nameof(Car.RegionId)); - - _licensePlateAttribute = - carResourceContext.Attributes.Single(attribute => - attribute.Property.Name == nameof(Car.LicensePlate)); - } - - public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) - { - if (expression.Left is ResourceFieldChainExpression leftChain && - expression.Right is LiteralConstantExpression rightConstant) - { - PropertyInfo leftProperty = leftChain.Fields.Last().Property; - if (IsCarId(leftProperty)) - { - if (expression.Operator != ComparisonOperator.Equals) - { - throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); - } - - return RewriteEqualityComparisonForCarStringId(rightConstant.Value); - } - } - - return base.VisitComparison(expression, argument); - } - - private static bool IsCarId(PropertyInfo property) - { - return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); - } - - private QueryExpression RewriteEqualityComparisonForCarStringId(string carStringId) - { - var tempCar = new Car - { - StringId = carStringId - }; - - return CreateEqualityComparisonOnRegionIdLicensePlate(tempCar.RegionId, tempCar.LicensePlate); - } - - private QueryExpression CreateEqualityComparisonOnRegionIdLicensePlate(long regionIdValue, - string licensePlateValue) - { - var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(_regionIdAttribute), - new LiteralConstantExpression(regionIdValue.ToString())); - - var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(_licensePlateAttribute), - new LiteralConstantExpression(licensePlateValue)); - - return new LogicalExpression(LogicalOperator.And, new[] - { - regionIdComparison, - licensePlateComparison - }); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index d590ef89e5..7266c5d007 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; @@ -482,154 +481,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => updated.Owner.Id.Should().Be(person.Id); }); } - - [Fact] - public async Task Can_Get_By_Composite_Key() - { - // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); - - var route = "/api/v1/cars/" + car.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(car.StringId); - } - - [Fact] - public async Task Can_Remove_Relationship_Of_Resource_With_Composite_Foreign_Key() - { - // Arrange - var engine = new Engine - { - SerialCode = "1234567890", - Car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Engines.Add(engine); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "engines", - id = engine.StringId, - relationships = new Dictionary - { - ["car"] = new - { - data = (object)null - } - } - } - }; - - var route = "/api/v1/engines/" + engine.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var engineInDatabase = await dbContext.Engines - .Include(e => e.Car) - .SingleAsync(e => e.Id == engine.Id); - - engineInDatabase.Car.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_Assign_Relationship_Of_Resource_With_Composite_Foreign_Key() - { - // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - var engine = new Engine - { - SerialCode = "1234567890" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(car, engine); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "engines", - id = engine.StringId, - relationships = new Dictionary - { - ["car"] = new - { - data = new - { - type = "cars", - id = car.StringId - } - } - } - } - }; - - var route = "/api/v1/engines/" + engine.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var engineInDatabase = await dbContext.Engines - .Include(e => e.Car) - .SingleAsync(e => e.Id == engine.Id); - - engineInDatabase.Car.Should().NotBeNull(); - engineInDatabase.Car.Id.Should().Be(car.StringId); - }); - } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs similarity index 93% rename from src/Examples/JsonApiDotNetCoreExample/Models/Car.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs index 991acff7f3..73994167e0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Car.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { public sealed class Car : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs new file mode 100644 index 0000000000..4251351818 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + /// + /// Rewrites an expression tree, updating all references to with + /// the combination of and . + /// + /// + /// This enables queries to use , which is not mapped in the database. + /// + public sealed class CarExpressionRewriter : QueryExpressionRewriter + { + private readonly AttrAttribute _regionIdAttribute; + private readonly AttrAttribute _licensePlateAttribute; + + public CarExpressionRewriter(IResourceContextProvider resourceContextProvider) + { + var carResourceContext = resourceContextProvider.GetResourceContext(); + + _regionIdAttribute = + carResourceContext.Attributes.Single(attribute => + attribute.Property.Name == nameof(Car.RegionId)); + + _licensePlateAttribute = + carResourceContext.Attributes.Single(attribute => + attribute.Property.Name == nameof(Car.LicensePlate)); + } + + public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) + { + if (expression.Left is ResourceFieldChainExpression leftChain && + expression.Right is LiteralConstantExpression rightConstant) + { + PropertyInfo leftProperty = leftChain.Fields.Last().Property; + if (IsCarId(leftProperty)) + { + if (expression.Operator != ComparisonOperator.Equals) + { + throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); + } + + return RewriteFilterOnCarStringIds(leftChain, new[] {rightConstant.Value}); + } + } + + return base.VisitComparison(expression, argument); + } + + public override QueryExpression VisitEqualsAnyOf(EqualsAnyOfExpression expression, object argument) + { + PropertyInfo property = expression.TargetAttribute.Fields.Last().Property; + if (IsCarId(property)) + { + var carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); + return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); + } + + return base.VisitEqualsAnyOf(expression, argument); + } + + public override QueryExpression VisitMatchText(MatchTextExpression expression, object argument) + { + PropertyInfo property = expression.TargetAttribute.Fields.Last().Property; + if (IsCarId(property)) + { + throw new NotSupportedException("Partial text matching on Car IDs is not possible."); + } + + return base.VisitMatchText(expression, argument); + } + + private static bool IsCarId(PropertyInfo property) + { + return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); + } + + private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, + IEnumerable carStringIds) + { + var outerTerms = new List(); + + foreach (var carStringId in carStringIds) + { + var tempCar = new Car + { + StringId = carStringId + }; + + var keyComparison = + CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate); + outerTerms.Add(keyComparison); + } + + return outerTerms.Count == 1 ? outerTerms[0] : new LogicalExpression(LogicalOperator.Or, outerTerms); + } + + private QueryExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, + long regionIdValue, string licensePlateValue) + { + var regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, + new LiteralConstantExpression(regionIdValue.ToString())); + + var licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); + var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, + new LiteralConstantExpression(licensePlateValue)); + + return new LogicalExpression(LogicalOperator.And, new[] + { + regionIdComparison, + licensePlateComparison + }); + } + + public override QueryExpression VisitSort(SortExpression expression, object argument) + { + var newSortElements = new List(); + + foreach (var sortElement in expression.Elements) + { + if (IsSortOnCarId(sortElement)) + { + var regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _regionIdAttribute); + newSortElements.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); + + var licensePlateSort = + ReplaceLastAttributeInChain(sortElement.TargetAttribute, _licensePlateAttribute); + newSortElements.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); + } + else + { + newSortElements.Add(sortElement); + } + } + + return new SortExpression(newSortElements); + } + + private static bool IsSortOnCarId(SortElementExpression sortElement) + { + if (sortElement.TargetAttribute != null) + { + PropertyInfo property = sortElement.TargetAttribute.Fields.Last().Property; + if (IsCarId(property)) + { + return true; + } + } + + return false; + } + + private static ResourceFieldChainExpression ReplaceLastAttributeInChain( + ResourceFieldChainExpression resourceFieldChain, AttrAttribute attribute) + { + var fields = resourceFieldChain.Fields.ToList(); + fields[^1] = attribute; + return new ResourceFieldChainExpression(fields); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs new file mode 100644 index 0000000000..d266ae4ae1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CarRepository : EntityFrameworkCoreRepository + { + private readonly IResourceGraph _resourceGraph; + + public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + _resourceGraph = resourceGraph; + } + + protected override IQueryable ApplyQueryLayer(QueryLayer layer) + { + RecursiveRewriteFilterInLayer(layer); + + return base.ApplyQueryLayer(layer); + } + + private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) + { + if (queryLayer.Filter != null) + { + var writer = new CarExpressionRewriter(_resourceGraph); + queryLayer.Filter = (FilterExpression) writer.Visit(queryLayer.Filter, null); + } + + if (queryLayer.Sort != null) + { + var writer = new CarExpressionRewriter(_resourceGraph); + queryLayer.Sort = (SortExpression) writer.Visit(queryLayer.Sort, null); + } + + if (queryLayer.Projection != null) + { + foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) + { + RecursiveRewriteFilterInLayer(nextLayer); + } + } + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/CarsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs similarity index 62% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/CarsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs index 90ec9ae3cf..f264c043e3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/CarsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs @@ -1,18 +1,16 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Controllers +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { public sealed class CarsController : JsonApiController { - public CarsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, + public CarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) - { } + { + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs new file mode 100644 index 0000000000..d62dd7e8f2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CompositeDbContext : DbContext + { + public DbSet Cars { get; set; } + public DbSet Engines { get; set; } + + public CompositeDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(c => new {c.RegionId, c.LicensePlate}); + + modelBuilder.Entity() + .HasOne(e => e.Car) + .WithOne(c => c.Engine) + .HasForeignKey(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs new file mode 100644 index 0000000000..874479d294 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -0,0 +1,328 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> + { + private readonly IntegrationTestContext, CompositeDbContext> _testContext; + + public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, CarRepository>(); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_filter_on_composite_key_in_primary_resources() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars?filter=any(id,'123:AA-BB-11','999:XX-YY-22')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars/" + car.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_sort_on_composite_primary_key() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars?sort=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_select_composite_primary_key() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars?fields=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_create_resource_with_composite_primary_key() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "cars", + attributes = new + { + regionId = 123, + licensePlate = "AA-BB-11" + } + } + }; + + var route = "/cars"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + } + + [Fact] + public async Task Can_remove_relationship_from_resource_with_composite_foreign_key() + { + // Arrange + var engine = new Engine + { + SerialCode = "1234567890", + Car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Engines.Add(engine); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "engines", + id = engine.StringId, + relationships = new Dictionary + { + ["car"] = new + { + data = (object)null + } + } + } + }; + + var route = "/engines/" + engine.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var engineInDatabase = await dbContext.Engines + .Include(e => e.Car) + .SingleAsync(e => e.Id == engine.Id); + + engineInDatabase.Car.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_assign_relationship_to_resource_with_composite_foreign_key() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + var engine = new Engine + { + SerialCode = "1234567890" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(car, engine); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "engines", + id = engine.StringId, + relationships = new Dictionary + { + ["car"] = new + { + data = new + { + type = "cars", + id = car.StringId + } + } + } + } + }; + + var route = "/engines/" + engine.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var engineInDatabase = await dbContext.Engines + .Include(e => e.Car) + .SingleAsync(e => e.Id == engine.Id); + + engineInDatabase.Car.Should().NotBeNull(); + engineInDatabase.Car.Id.Should().Be(car.StringId); + }); + } + + [Fact] + public async Task Can_delete_resource_with_composite_primary_key() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars/" + car.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs similarity index 78% rename from src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs index a9319dee59..33ecaf4b6c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Engine.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { public sealed class Engine : Identifiable { diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs similarity index 62% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs index 73499102de..4833292cd8 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/EnginesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs @@ -1,18 +1,16 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Controllers +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { public sealed class EnginesController : JsonApiController { - public EnginesController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, + public EnginesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) - { } + { + } } } From 7cff727747aa56e91cb6f3126d3d16fd53e78c36 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 18:36:26 +0200 Subject: [PATCH 077/240] Added TODO --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 12e5551bd1..c924b5db77 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -214,6 +214,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource var updated = await _update.UpdateAsync(id, resource); + // TODO: json:api spec says to return 204 without body when no side-effects. return updated == null ? Ok(null) : Ok(updated); } From ea16a8b4b5d7388029644be310104ba771930479 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 19:25:52 +0200 Subject: [PATCH 078/240] clean; update test to allow deleting non-existing relationship --- .../EntityFrameworkCoreRepository.cs | 31 ++++++++++++++----- .../Spec/UpdatingRelationshipsTests.cs | 5 +++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index b5ff5931d9..ff31a209cf 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -256,17 +256,35 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< await LoadRelationship(relationship, primaryResource); - var currentRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); - var newRightResources = currentRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToArray(); + var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); + var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, + secondaryResourceIds.Select(x => x.StringId)); - // TODO: What does this < comparison mean? - if (newRightResources.Length < currentRightResources.Count) + if (newRightResources.Count != existingRightResources.Count) { await AssignValueToRelationship(relationship, primaryResource, newRightResources); await SaveChangesAsync(); } } + /// + /// Removes resources from whose ID exists in . + /// + /// + /// + /// + private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( + IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) + { + var newRightResources = new HashSet(existingRightResources); + newRightResources.RemoveWhere(r => resourceIdsToRemove.Any(stringId => r.StringId == stringId)); + return newRightResources; + } + private TResource CreatePrimaryResourceWithAssignedId(TId id) { var resource = _resourceFactory.CreateInstance(); @@ -330,15 +348,14 @@ protected async Task LoadRelationship(RelationshipAttribute relationship, TResou if (resource == null) throw new ArgumentNullException(nameof(resource)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - var navigationEntry = GetNavigationEntry(relationship, resource); + var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); if (navigationEntry != null) { await navigationEntry.LoadAsync(); } } - // TODO: Rename this method to something better. - private NavigationEntry GetNavigationEntry(RelationshipAttribute relationship, TResource resource) + private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) { EntityEntry entityEntry = _dbContext.Entry(resource); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 4685c35ece..ca18e1e1a3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -666,6 +666,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "todoItems", id = todoItemToDelete.StringId + }, + new + { + type = "todoItems", + id = "99999999" } } }; From 4140c6a53826a08bc90b392f2e3f1d386694b2b1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Oct 2020 19:53:52 +0200 Subject: [PATCH 079/240] added remarks --- .../Controllers/BaseJsonApiController.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index c924b5db77..70ccfaa718 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -173,6 +173,7 @@ public virtual async Task PostAsync([FromBody] TResource resource resource = await _create.CreateAsync(resource); + // TODO: When options.AllowClientGeneratedIds, should run change tracking similar to Patch, and return 201 or 204 (see json:api spec). return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); } @@ -191,6 +192,13 @@ public virtual async Task PostRelationshipAsync(TId id, string re if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + // TODO: Silently ignore already-existing entries, causing duplicates. From json:api spec: + // "If a client makes a POST request to a URL from a relationship link, the server MUST add the specified members to the relationship unless they are already present. If a given type and id is already in the relationship, the server MUST NOT add it again" + // "Note: This matches the semantics of databases that use foreign keys for has-many relationships. Document-based storage should check the has-many relationship before appending to avoid duplicates." + // "If all of the specified resources can be added to, or are already present in, the relationship then the server MUST return a successful response." + // "Note: This approach ensures that a request is successful if the server’s state matches the requested state, and helps avoid pointless race conditions caused by multiple clients making the same changes to a relationship." + + // TODO: Should return 204 when relationship already exists (see json:api spec) + ensure we have a test covering this. return Ok(); } @@ -214,7 +222,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource var updated = await _update.UpdateAsync(id, resource); - // TODO: json:api spec says to return 204 without body when no side-effects. + // TODO: json:api spec says to return 204 without body when no side-effects. See other comments on how this could be interpreted for relationships too. return updated == null ? Ok(null) : Ok(updated); } @@ -266,6 +274,7 @@ public virtual async Task DeleteRelationshipAsync(TId id, string if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + // TODO: Should return 204 when relationship does not exist (see json:api spec) + ensure we have a test covering this. return Ok(); } } From 0bc914b9610c0af1bd02edbab27503e6b904ded8 Mon Sep 17 00:00:00 2001 From: maurei Date: Sat, 17 Oct 2020 12:34:02 +0200 Subject: [PATCH 080/240] chore: replied to a Todo, removed flushing and simplified helper method name --- .../Repositories/EntityFrameworkCoreRepository.cs | 15 ++++++++++----- .../Repositories/IResourceWriteRepository.cs | 5 ----- .../Services/JsonApiResourceService.cs | 7 ++++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index ff31a209cf..5175511643 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -164,7 +164,7 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) var relationship = _targetedFields.Relationships.Single(); TResource primaryResource; - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSideOfHasOneRelationship(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { primaryResource = await _dbContext.Set() .Include(relationship.Property.Name) @@ -200,10 +200,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSideOfHasOneRelationship(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { - FlushFromCache(resourceFromDatabase); - resourceFromDatabase = await _dbContext.Set() // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? .Include(relationship.Property.Name) @@ -215,6 +213,11 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r // TODO: I believe the comment below does not apply here (anymore). The calling resource service always fetches the entire record. // And commenting out the line below still keeps all tests green. // Does this comment maybe apply to SetRelationshipAsync()? + + // Maurits: We tried moving the update logic to the repo without success. Now that we're keeping + // it this (i.e. service doing a repo.GetAsync and then calling repo.UpdateAsync), I think it is good to + // keep it a repo responsibility to make sure that the provided database resource is actually present in the change tracker + // because there is no guarantee it is. // A database entity might not be tracked if it was retrieved through projection. resourceFromDatabase = (TResource) _dbContext.GetTrackedOrAttach(resourceFromDatabase); @@ -233,6 +236,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r } await SaveChangesAsync(); + + FlushFromCache(resourceFromDatabase); } /// @@ -439,7 +444,7 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private bool HasForeignKeyAtLeftSideOfHasOneRelationship(HasOneAttribute relationship) + private bool HasForeignKeyAtLeftSide(HasOneAttribute relationship) { var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); var navigation = entityType.FindNavigation(relationship.Property.Name); diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 05859686b9..da1f8a5d0b 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -48,10 +48,5 @@ public interface IResourceWriteRepository /// Removes resources from a to-many relationship in the underlying data store. /// Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds); - - /// - /// Ensures that the next time a given resource is requested, it is re-fetched from the underlying data store. - /// - void FlushFromCache(TResource resource); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6cbb9c6b15..6700cfad63 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -253,7 +253,9 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR _traceWriter.LogMethodStart(new {id, resourceFromRequest}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); - TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes); + // TODO: optimization; if TargetedFields.Attributes.Length = 0, we can do TopFieldSelection.OnlyIdAttribute instead of AllAttributes. + var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; + TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -278,8 +280,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); } - - _repository.FlushFromCache(resourceFromDatabase); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); From 159b6d6f6e031027bfbac89f8a1f717ac80706e1 Mon Sep 17 00:00:00 2001 From: maurei Date: Sat, 17 Oct 2020 15:46:28 +0200 Subject: [PATCH 081/240] feat: add FK relationship assignment optimalization in case of relationship updates from dependent side --- .../EntityFrameworkCoreRepository.cs | 87 +++++++++++++++---- .../Services/JsonApiResourceService.cs | 3 +- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 5175511643..3a5ef36b46 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -13,6 +14,7 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -202,11 +204,16 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r { if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { - resourceFromDatabase = await _dbContext.Set() - // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? - .Include(relationship.Property.Name) - .Where(resource => resource.Id.Equals(resourceFromRequest.Id)) - .FirstAsync(); + // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? + var query = _dbContext.Set().Where(resource => resource.Id.Equals(resourceFromRequest.Id)); + + var rightResource = relationship.GetValue(resourceFromRequest); + if (rightResource == null) + { + query = query.Include(relationship.Property.Name); + } + + resourceFromDatabase = await query.FirstAsync(); } else { @@ -299,11 +306,11 @@ private TResource CreatePrimaryResourceWithAssignedId(TId id) } /// - public virtual void FlushFromCache(TResource resource) + private void FlushFromCache(TResource resource) { _traceWriter.LogMethodStart(new {resource}); - var trackedResource = _dbContext.GetTrackedIdentifiable(resource); + var trackedResource = _dbContext.GetTrackedOrAttach(resource); _dbContext.Entry(trackedResource).State = EntityState.Detached; } @@ -389,17 +396,22 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute /// private async Task LoadInverseForOneToOneRelationship(RelationshipAttribute relationship, object resource) { - if (relationship.InverseNavigationProperty != null && relationship is HasOneAttribute hasOneRelationship) + if (relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship)) { - var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); - var isOneToOneRelationship = elementType == null; + var entityEntry = _dbContext.Entry(resource); + await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); + } + } - if (isOneToOneRelationship) - { - var entityEntry = _dbContext.Entry(resource); - await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); - } + private static bool IsOneToOneRelationship(RelationshipAttribute relationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); + return elementType == null; } + + return false; } private async Task AssignValueToRelationship(RelationshipAttribute relationship, TResource leftResource, @@ -411,9 +423,38 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, { await LoadInverseForOneToOneRelationship(relationship, trackedValueToAssign); } - + + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) + { + var foreignKeyProperties = GetForeignKeyProperties(hasOneRelationship); + if (foreignKeyProperties.Count == 1) + { + SetValueThroughForeignKeyProperty(foreignKeyProperties.First(), leftResource, trackedValueToAssign); + } + } + relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } + + private void SetValueThroughForeignKeyProperty(IProperty foreignKeyProperty, TResource leftResource, object valueToAssign) + { + var rightResourceId = valueToAssign is IIdentifiable rightResource + ? rightResource.GetTypedId() + : null; + + if (foreignKeyProperty.IsShadowProperty()) + { + // When assigning a FK through a shadow property, EF Core will handle updating the EntityState. + _dbContext.Entry(leftResource).Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; + } + else + { + foreignKeyProperty.PropertyInfo.SetValue(leftResource, rightResourceId); + + // When assigning a FK through a regular property, we are responsible ourselves for updating the EntityState. + _dbContext.Entry(leftResource).State = EntityState.Modified; + } + } private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) { @@ -446,10 +487,18 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl private bool HasForeignKeyAtLeftSide(HasOneAttribute relationship) { - var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); - var navigation = entityType.FindNavigation(relationship.Property.Name); + var foreignKeyProperties = GetForeignKeyProperties(relationship); + var leftSideHasForeignKey = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); + + return leftSideHasForeignKey; + } - return navigation.ForeignKey.DeclaringEntityType.ClrType == typeof(TResource); + private IReadOnlyList GetForeignKeyProperties(HasOneAttribute relationship) + { + var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + var foreignKeyMetadata = entityType.FindNavigation(relationship.Property.Name).ForeignKey; + + return foreignKeyMetadata.Properties; } private async Task SaveChangesAsync() diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6700cfad63..61b32b6f9c 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -254,6 +254,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); // TODO: optimization; if TargetedFields.Attributes.Length = 0, we can do TopFieldSelection.OnlyIdAttribute instead of AllAttributes. + // This is a problem though with implicit change tracking: implicit changes can not be tracked. Do we think this is important? var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); @@ -281,7 +282,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); } - TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); From 18fae52bfd11e0bb0d11d2b5e375623c4236b7d4 Mon Sep 17 00:00:00 2001 From: maurei Date: Sat, 17 Oct 2020 16:42:38 +0200 Subject: [PATCH 082/240] chore: refactor around SetRelationshipsAsync and UpdateAsync --- .../EntityFrameworkCoreRepository.cs | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3a5ef36b46..4de54016ec 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,7 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -164,15 +163,12 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); var relationship = _targetedFields.Relationships.Single(); - TResource primaryResource; + TResource primaryResource = CreatePrimaryResourceWithAssignedId(id); if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { - primaryResource = await _dbContext.Set() - .Include(relationship.Property.Name) - .Where(resource => resource.Id.Equals(id)) - .FirstOrDefaultAsync(); - + primaryResource = await LoadResourceAndRelationship(relationship, id, secondaryResourceIds); + if (primaryResource == null) { var tempResource = CreatePrimaryResourceWithAssignedId(id); @@ -182,8 +178,7 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) } else { - primaryResource = CreatePrimaryResourceWithAssignedId(id); - primaryResource = (TResource) _dbContext.GetTrackedOrAttach(primaryResource); + primaryResource = (TResource)_dbContext.GetTrackedOrAttach(primaryResource); await LoadRelationship(relationship, primaryResource); } @@ -204,16 +199,9 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r { if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { + var rightResourceId = (relationship.GetValue(resourceFromRequest) as IIdentifiable)?.GetTypedId(); // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? - var query = _dbContext.Set().Where(resource => resource.Id.Equals(resourceFromRequest.Id)); - - var rightResource = relationship.GetValue(resourceFromRequest); - if (rightResource == null) - { - query = query.Include(relationship.Property.Name); - } - - resourceFromDatabase = await query.FirstAsync(); + resourceFromDatabase = await LoadResourceAndRelationship(relationship, resourceFromRequest.Id, rightResourceId); } else { @@ -227,7 +215,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r // because there is no guarantee it is. // A database entity might not be tracked if it was retrieved through projection. - resourceFromDatabase = (TResource) _dbContext.GetTrackedOrAttach(resourceFromDatabase); + resourceFromDatabase = (TResource)_dbContext.GetTrackedOrAttach(resourceFromDatabase); // Ensures complete replacement of the relationship. await LoadRelationship(relationship, resourceFromDatabase); @@ -247,6 +235,20 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r FlushFromCache(resourceFromDatabase); } + private async Task LoadResourceAndRelationship(RelationshipAttribute relationship, TId leftResourceId, object rightResourceId) + { + var primaryResourceQuery = _dbContext.Set().Where(resource => resource.Id.Equals(leftResourceId)); + + var relationshipIsNulled = rightResourceId == null; + if (relationshipIsNulled) + { + primaryResourceQuery = primaryResourceQuery.Include(relationship.Property.Name); + } + + var primaryResource = await primaryResourceQuery.FirstOrDefaultAsync(); + return primaryResource; + } + /// public virtual async Task DeleteAsync(TId id) { @@ -403,7 +405,7 @@ private async Task LoadInverseForOneToOneRelationship(RelationshipAttribute rela } } - private static bool IsOneToOneRelationship(RelationshipAttribute relationship) + private bool IsOneToOneRelationship(RelationshipAttribute relationship) { if (relationship is HasOneAttribute hasOneRelationship) { From ebbf430bde0a1cfcbf807c6bfbf27da38e035ed1 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 18 Oct 2020 12:01:52 +0200 Subject: [PATCH 083/240] fix: debugged foreign key assignment optimization in repository --- .../Repositories/DbContextExtensions.cs | 47 +++++--- .../EntityFrameworkCoreRepository.cs | 101 +++++++++--------- 2 files changed, 85 insertions(+), 63 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 48e51c48dc..6f80f320c9 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,27 +1,14 @@ using System; using System.Linq; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; namespace JsonApiDotNetCore.Repositories { public static class DbContextExtensions { - public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) - { - if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); - if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); - - var entityType = identifiable.GetType(); - var entityEntry = dbContext.ChangeTracker - .Entries() - .FirstOrDefault(entry => - entry.Entity.GetType() == entityType && - ((IIdentifiable) entry.Entity).StringId == identifiable.StringId); - - return entityEntry?.Entity; - } - public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); @@ -36,5 +23,35 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti return trackedIdentifiable; } + + public static IProperty GetSingleForeignKeyProperty(this DbContext dbContext, HasOneAttribute relationship) + { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + + var entityType = dbContext.Model.FindEntityType(relationship.LeftType); + var foreignKeyProperties = entityType.FindNavigation(relationship.Property.Name).ForeignKey.Properties; + + if (foreignKeyProperties.Count != 1) + { + throw new ArgumentException($"Relationship {relationship} does not have a left side with a single foreign key"); + } + + return foreignKeyProperties.First(); + } + + private static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + + var entityType = identifiable.GetType(); + var entityEntry = dbContext.ChangeTracker + .Entries() + .FirstOrDefault(entry => + entry.Entity.GetType() == entityType && + ((IIdentifiable) entry.Entity).StringId == identifiable.StringId); + + return entityEntry?.Entity; + } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 4de54016ec..40417b4b21 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -165,15 +165,17 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) var relationship = _targetedFields.Relationships.Single(); TResource primaryResource = CreatePrimaryResourceWithAssignedId(id); - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) { - primaryResource = await LoadResourceAndRelationship(relationship, id, secondaryResourceIds); - - if (primaryResource == null) + if (secondaryResourceIds == null) { - var tempResource = CreatePrimaryResourceWithAssignedId(id); - var resourceContext = _resourceGraph.GetResourceContext(); - throw new ResourceNotFoundException(tempResource.StringId, resourceContext.PublicName); + primaryResource = await LoadResourceAndRelationship(hasOneRelationship, id); + if (primaryResource == null) + { + var tempResource = CreatePrimaryResourceWithAssignedId(id); + var resourceContext = _resourceGraph.GetResourceContext(); + throw new ResourceNotFoundException(tempResource.StringId, resourceContext.PublicName); + } } } else @@ -194,29 +196,36 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); + + // TODO: I believe the comment below does not apply here (anymore). The calling resource service always fetches the entire record. + // And commenting out the line below still keeps all tests green. + // Does this comment maybe apply to SetRelationshipAsync()? + + // Maurits: We tried moving the update logic to the repo without success. Now that we're keeping + // it this (i.e. service doing a repo.GetAsync and then calling repo.UpdateAsync), I think it is good to + // keep it a repo responsibility to make sure that the provided database resource is actually present in the change tracker + // because there is no guarantee it is. + + // A database entity might not be tracked if it was retrieved through projection. + resourceFromDatabase = (TResource)_dbContext.GetTrackedOrAttach(resourceFromDatabase); + // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) { - var rightResourceId = (relationship.GetValue(resourceFromRequest) as IIdentifiable)?.GetTypedId(); // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? - resourceFromDatabase = await LoadResourceAndRelationship(relationship, resourceFromRequest.Id, rightResourceId); + var rightResourceId = (relationship.GetValue(resourceFromRequest) as IIdentifiable)?.GetTypedId(); + if (rightResourceId == null) + { + // If the foreign keys live on the primary resource there is no need to load the relationship, + // unless the relationship is being set to null. In this case without having loaded the relationship + // we will be assigning null to null, which does not trigger entry in EF Cores change tracker. + resourceFromDatabase = await LoadResourceAndRelationship(hasOneRelationship, resourceFromRequest.Id); + } } else { - // TODO: I believe the comment below does not apply here (anymore). The calling resource service always fetches the entire record. - // And commenting out the line below still keeps all tests green. - // Does this comment maybe apply to SetRelationshipAsync()? - - // Maurits: We tried moving the update logic to the repo without success. Now that we're keeping - // it this (i.e. service doing a repo.GetAsync and then calling repo.UpdateAsync), I think it is good to - // keep it a repo responsibility to make sure that the provided database resource is actually present in the change tracker - // because there is no guarantee it is. - - // A database entity might not be tracked if it was retrieved through projection. - resourceFromDatabase = (TResource)_dbContext.GetTrackedOrAttach(resourceFromDatabase); - // Ensures complete replacement of the relationship. await LoadRelationship(relationship, resourceFromDatabase); } @@ -235,17 +244,15 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r FlushFromCache(resourceFromDatabase); } - private async Task LoadResourceAndRelationship(RelationshipAttribute relationship, TId leftResourceId, object rightResourceId) + private async Task LoadResourceAndRelationship(HasOneAttribute relationship, TId leftResourceId) { - var primaryResourceQuery = _dbContext.Set().Where(resource => resource.Id.Equals(leftResourceId)); - var relationshipIsNulled = rightResourceId == null; - if (relationshipIsNulled) - { - primaryResourceQuery = primaryResourceQuery.Include(relationship.Property.Name); - } + + var primaryResourceQuery = _dbContext.Set().Where(resource => resource.Id.Equals(leftResourceId)); + + primaryResourceQuery = primaryResourceQuery.Include(relationship.Property.Name); - var primaryResource = await primaryResourceQuery.FirstOrDefaultAsync(); + var primaryResource = await primaryResourceQuery.FirstOrDefaultAsync(); return primaryResource; } @@ -426,16 +433,15 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, await LoadInverseForOneToOneRelationship(relationship, trackedValueToAssign); } - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) { - var foreignKeyProperties = GetForeignKeyProperties(hasOneRelationship); - if (foreignKeyProperties.Count == 1) - { - SetValueThroughForeignKeyProperty(foreignKeyProperties.First(), leftResource, trackedValueToAssign); - } + var foreignKeyProperty = GetForeignKeyProperties(hasOneRelationship).First(); + SetValueThroughForeignKeyProperty(foreignKeyProperty, leftResource, trackedValueToAssign); + } + else + { + relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } - - relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } private void SetValueThroughForeignKeyProperty(IProperty foreignKeyProperty, TResource leftResource, object valueToAssign) @@ -443,19 +449,18 @@ private void SetValueThroughForeignKeyProperty(IProperty foreignKeyProperty, TRe var rightResourceId = valueToAssign is IIdentifiable rightResource ? rightResource.GetTypedId() : null; - + + var entityEntry = _dbContext.Entry(leftResource); if (foreignKeyProperty.IsShadowProperty()) { - // When assigning a FK through a shadow property, EF Core will handle updating the EntityState. - _dbContext.Entry(leftResource).Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; + entityEntry.Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; } else { foreignKeyProperty.PropertyInfo.SetValue(leftResource, rightResourceId); - - // When assigning a FK through a regular property, we are responsible ourselves for updating the EntityState. - _dbContext.Entry(leftResource).State = EntityState.Modified; } + + entityEntry.State = EntityState.Modified; } private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) @@ -487,12 +492,12 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private bool HasForeignKeyAtLeftSide(HasOneAttribute relationship) + private bool HasSingleForeignKeyAtLeftSide(HasOneAttribute relationship) { var foreignKeyProperties = GetForeignKeyProperties(relationship); - var leftSideHasForeignKey = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); - - return leftSideHasForeignKey; + var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); + var hasSingleForeignKey = foreignKeyProperties.Count == 1; + return hasForeignKeyOnLeftSide && hasSingleForeignKey; } private IReadOnlyList GetForeignKeyProperties(HasOneAttribute relationship) From e5ffdbc5f7139a781ff8fe1dcaf318417cdd96f3 Mon Sep 17 00:00:00 2001 From: maurei Date: Sun, 18 Oct 2020 20:09:43 +0200 Subject: [PATCH 084/240] fix: improved FK optimization --- .../EntityFrameworkCoreRepository.cs | 67 +++++++------------ .../ModelStateValidationTests.cs | 11 +++ 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 40417b4b21..5aeb4fca16 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -165,18 +165,9 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) var relationship = _targetedFields.Relationships.Single(); TResource primaryResource = CreatePrimaryResourceWithAssignedId(id); - if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { - if (secondaryResourceIds == null) - { - primaryResource = await LoadResourceAndRelationship(hasOneRelationship, id); - if (primaryResource == null) - { - var tempResource = CreatePrimaryResourceWithAssignedId(id); - var resourceContext = _resourceGraph.GetResourceContext(); - throw new ResourceNotFoundException(tempResource.StringId, resourceContext.PublicName); - } - } + // noop, refactor } else { @@ -212,17 +203,9 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { - if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) + if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) { - // TODO: Can/should we unify this, instead of executing a new query for each individual one-to-one relationship? - var rightResourceId = (relationship.GetValue(resourceFromRequest) as IIdentifiable)?.GetTypedId(); - if (rightResourceId == null) - { - // If the foreign keys live on the primary resource there is no need to load the relationship, - // unless the relationship is being set to null. In this case without having loaded the relationship - // we will be assigning null to null, which does not trigger entry in EF Cores change tracker. - resourceFromDatabase = await LoadResourceAndRelationship(hasOneRelationship, resourceFromRequest.Id); - } + // noop, refactor } else { @@ -246,13 +229,11 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r private async Task LoadResourceAndRelationship(HasOneAttribute relationship, TId leftResourceId) { - var primaryResourceQuery = _dbContext.Set().Where(resource => resource.Id.Equals(leftResourceId)); primaryResourceQuery = primaryResourceQuery.Include(relationship.Property.Name); - - var primaryResource = await primaryResourceQuery.FirstOrDefaultAsync(); + var primaryResource = await primaryResourceQuery.FirstOrDefaultAsync(); return primaryResource; } @@ -279,7 +260,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, - secondaryResourceIds.Select(x => x.StringId)); + secondaryResourceIds.Select(r => r.StringId)); if (newRightResources.Count != existingRightResources.Count) { @@ -405,11 +386,8 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute /// private async Task LoadInverseForOneToOneRelationship(RelationshipAttribute relationship, object resource) { - if (relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship)) - { - var entityEntry = _dbContext.Entry(resource); - await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); - } + var entityEntry = _dbContext.Entry(resource); + await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); } private bool IsOneToOneRelationship(RelationshipAttribute relationship) @@ -428,7 +406,7 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, { // Ensures the new relationship assignment will not result in entities being tracked more than once. var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - if (trackedValueToAssign != null) + if (trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship)) { await LoadInverseForOneToOneRelationship(relationship, trackedValueToAssign); } @@ -436,11 +414,16 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) { var foreignKeyProperty = GetForeignKeyProperties(hasOneRelationship).First(); - SetValueThroughForeignKeyProperty(foreignKeyProperty, leftResource, trackedValueToAssign); + SetValueThroughForeignKeyProperty(foreignKeyProperty, leftResource, valueToAssign); } else { relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); + if (trackedValueToAssign == null) + { + var entry = GetNavigationEntryForRelationship(relationship, leftResource); + entry.IsModified = true; + } } } @@ -450,17 +433,10 @@ private void SetValueThroughForeignKeyProperty(IProperty foreignKeyProperty, TRe ? rightResource.GetTypedId() : null; + // https://stackoverflow.com/questions/10257360/how-to-update-not-every-fields-of-an-object-using-entity-framework-and-entitysta var entityEntry = _dbContext.Entry(leftResource); - if (foreignKeyProperty.IsShadowProperty()) - { - entityEntry.Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; - } - else - { - foreignKeyProperty.PropertyInfo.SetValue(leftResource, rightResourceId); - } - - entityEntry.State = EntityState.Modified; + entityEntry.Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; + entityEntry.Property(foreignKeyProperty.Name).IsModified = true; } private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) @@ -499,6 +475,13 @@ private bool HasSingleForeignKeyAtLeftSide(HasOneAttribute relationship) var hasSingleForeignKey = foreignKeyProperties.Count == 1; return hasForeignKeyOnLeftSide && hasSingleForeignKey; } + + private bool HasForeignKeyAtLeftSide(HasOneAttribute relationship) + { + var foreignKeyProperties = GetForeignKeyProperties(relationship); + var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); + return hasForeignKeyOnLeftSide; + } private IReadOnlyList GetForeignKeyProperties(HasOneAttribute relationship) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 361788d5be..9325154e06 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Xunit; @@ -820,6 +822,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.Data.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var assertDirectory = await dbContext.Directories.Where(d => d.Id == directory.Id) + .Include(d => d.Parent) + .FirstOrDefaultAsync(); + + assertDirectory.Parent.Id.Should().Be(otherParent.Id); + }); } [Fact] From 7686cbfdf7a787d9053dbcb4e70850fdc8ac52c7 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 19 Oct 2020 10:41:29 +0200 Subject: [PATCH 085/240] chore: refactor --- .../EntityFrameworkCoreRepository.cs | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 5aeb4fca16..add43d35cc 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -163,16 +163,10 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); var relationship = _targetedFields.Relationships.Single(); - TResource primaryResource = CreatePrimaryResourceWithAssignedId(id); + TResource primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) + if (!HasForeignKeyAtLeftSide(relationship)) { - // noop, refactor - } - else - { - primaryResource = (TResource)_dbContext.GetTrackedOrAttach(primaryResource); - await LoadRelationship(relationship, primaryResource); } @@ -203,13 +197,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { - if (relationship is HasOneAttribute hasOneRelationship && HasForeignKeyAtLeftSide(hasOneRelationship)) + if (!HasForeignKeyAtLeftSide(relationship)) { - // noop, refactor - } - else - { - // Ensures complete replacement of the relationship. await LoadRelationship(relationship, resourceFromDatabase); } @@ -411,9 +400,9 @@ private async Task AssignValueToRelationship(RelationshipAttribute relationship, await LoadInverseForOneToOneRelationship(relationship, trackedValueToAssign); } - if (relationship is HasOneAttribute hasOneRelationship && HasSingleForeignKeyAtLeftSide(hasOneRelationship)) + if (HasSingleForeignKeyAtLeftSide(relationship)) { - var foreignKeyProperty = GetForeignKeyProperties(hasOneRelationship).First(); + var foreignKeyProperty = GetForeignKeyProperties((HasOneAttribute)relationship).First(); SetValueThroughForeignKeyProperty(foreignKeyProperty, leftResource, valueToAssign); } else @@ -468,19 +457,29 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private bool HasSingleForeignKeyAtLeftSide(HasOneAttribute relationship) + private bool HasSingleForeignKeyAtLeftSide(RelationshipAttribute relationship) { - var foreignKeyProperties = GetForeignKeyProperties(relationship); - var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); - var hasSingleForeignKey = foreignKeyProperties.Count == 1; - return hasForeignKeyOnLeftSide && hasSingleForeignKey; + if (relationship is HasOneAttribute hasOneRelationship) + { + var foreignKeyProperties = GetForeignKeyProperties(hasOneRelationship); + var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); + var hasSingleForeignKey = foreignKeyProperties.Count == 1; + return hasForeignKeyOnLeftSide && hasSingleForeignKey; + } + + return false; } - private bool HasForeignKeyAtLeftSide(HasOneAttribute relationship) + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) { - var foreignKeyProperties = GetForeignKeyProperties(relationship); - var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); - return hasForeignKeyOnLeftSide; + if (relationship is HasOneAttribute hasOneRelationship) + { + var foreignKeyProperties = GetForeignKeyProperties(hasOneRelationship); + var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); + return hasForeignKeyOnLeftSide; + } + + return false; } private IReadOnlyList GetForeignKeyProperties(HasOneAttribute relationship) From 50dd4815a1294815bb094ec987ae61f26cef2283 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 19 Oct 2020 11:18:21 +0200 Subject: [PATCH 086/240] chore: cleanup --- .../Repositories/DbContextExtensions.cs | 15 --------------- .../Repositories/EntityFrameworkCoreRepository.cs | 10 ---------- 2 files changed, 25 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 6f80f320c9..3f45f08bd8 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -24,21 +24,6 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti return trackedIdentifiable; } - public static IProperty GetSingleForeignKeyProperty(this DbContext dbContext, HasOneAttribute relationship) - { - if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - - var entityType = dbContext.Model.FindEntityType(relationship.LeftType); - var foreignKeyProperties = entityType.FindNavigation(relationship.Property.Name).ForeignKey.Properties; - - if (foreignKeyProperties.Count != 1) - { - throw new ArgumentException($"Relationship {relationship} does not have a left side with a single foreign key"); - } - - return foreignKeyProperties.First(); - } - private static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index add43d35cc..16c0f0876e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -216,16 +216,6 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r FlushFromCache(resourceFromDatabase); } - private async Task LoadResourceAndRelationship(HasOneAttribute relationship, TId leftResourceId) - { - - var primaryResourceQuery = _dbContext.Set().Where(resource => resource.Id.Equals(leftResourceId)); - - primaryResourceQuery = primaryResourceQuery.Include(relationship.Property.Name); - var primaryResource = await primaryResourceQuery.FirstOrDefaultAsync(); - return primaryResource; - } - /// public virtual async Task DeleteAsync(TId id) { From e9ae2a0bb18d306037ba7772ec428fe995280cbd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 19 Oct 2020 11:19:10 +0200 Subject: [PATCH 087/240] cleanup --- .../Repositories/EntityFrameworkCoreRepository.cs | 2 +- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 5 +++-- .../Acceptance/InjectableResourceTests.cs | 1 + .../ModelStateValidation/ModelStateValidationTests.cs | 9 +++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 5aeb4fca16..722062ebd6 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -295,11 +295,11 @@ private TResource CreatePrimaryResourceWithAssignedId(TId id) return resource; } - /// private void FlushFromCache(TResource resource) { _traceWriter.LogMethodStart(new {resource}); + // TODO: Check if this change can be reverted (use GetTrackedIdentifiable). var trackedResource = _dbContext.GetTrackedOrAttach(resource); _dbContext.Entry(trackedResource).State = EntityState.Detached; } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 61b32b6f9c..2a349bda43 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -255,7 +255,8 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR // TODO: optimization; if TargetedFields.Attributes.Length = 0, we can do TopFieldSelection.OnlyIdAttribute instead of AllAttributes. // This is a problem though with implicit change tracking: implicit changes can not be tracked. Do we think this is important? - var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; + //var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; + var fieldsToSelect = TopFieldSelection.AllAttributes; TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); @@ -281,7 +282,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); } - + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index 6a800f6592..df681b6e01 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -196,6 +196,7 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() resource => resource.Attributes.ContainsKey("lastName")); } + // TODO: We agreed this test needs to be refactored, not disabled. [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] public async Task Fail_When_Deleting_Missing_Passport() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 9325154e06..155d54c3e7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -822,14 +822,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.Data.Should().BeNull(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertDirectory = await dbContext.Directories.Where(d => d.Id == directory.Id) + var directoryInDatabase = await dbContext.Directories .Include(d => d.Parent) - .FirstOrDefaultAsync(); + .Where(d => d.Id == directory.Id) + .SingleAsync(); - assertDirectory.Parent.Id.Should().Be(otherParent.Id); + directoryInDatabase.Parent.Id.Should().Be(otherParent.Id); }); } From a1a573632dcc6643aae65730253f94bc21191140 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 19 Oct 2020 11:35:32 +0200 Subject: [PATCH 088/240] comments --- .../Repositories/EntityFrameworkCoreRepository.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 18b270ed84..473582f3cf 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -214,6 +214,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r await SaveChangesAsync(); FlushFromCache(resourceFromDatabase); + + // TODO: Should we call DetachRelationships here, similar to Create? } /// @@ -363,6 +365,7 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute /// Loads the inverse of a one-to-one relationship, to support an implicit remove. This prevents a foreign key constraint from being violated. /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. /// + // TODO: Rename, this is no longer specific to one-to-one and does not affect inverse. private async Task LoadInverseForOneToOneRelationship(RelationshipAttribute relationship, object resource) { var entityEntry = _dbContext.Entry(resource); From 8e3ffb3593dd401de9b0c57112f21a4539fe8918 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 19 Oct 2020 18:38:28 +0200 Subject: [PATCH 089/240] feat: remove dependency on foreign keys --- .../EntityFrameworkCoreRepository.cs | 244 +++++++++--------- .../Resources/ResourceFactory.cs | 2 +- .../Spec/UpdatingRelationshipsTests.cs | 49 ++++ 3 files changed, 177 insertions(+), 118 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 473582f3cf..8cac9e2267 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -13,7 +12,6 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -118,7 +116,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) var expression = builder.ApplyQuery(layer); return source.Provider.CreateQuery(expression); } - + protected virtual IQueryable GetAll() { return _dbContext.Set(); @@ -133,7 +131,7 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationship in _targetedFields.Relationships) { var rightValue = relationship.GetValue(resource); - await AssignValueToRelationship(relationship, resource, rightValue); + await ProcessRelationshipUpdate(relationship, resource, rightValue); } _dbContext.Set().Add(resource); @@ -146,7 +144,8 @@ public virtual async Task CreateAsync(TResource resource) DetachRelationships(resource); } - public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) + /// + public virtual async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); @@ -154,11 +153,12 @@ public async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection + public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); @@ -170,7 +170,7 @@ public async Task SetRelationshipAsync(TId id, object secondaryResourceIds) await LoadRelationship(relationship, primaryResource); } - await AssignValueToRelationship(relationship, primaryResource, secondaryResourceIds); + await ProcessRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } @@ -193,7 +193,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r // A database entity might not be tracked if it was retrieved through projection. resourceFromDatabase = (TResource)_dbContext.GetTrackedOrAttach(resourceFromDatabase); - + // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { @@ -203,7 +203,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r } var relationshipAssignment = relationship.GetValue(resourceFromRequest); - await AssignValueToRelationship(relationship, resourceFromDatabase, relationshipAssignment); + await ProcessRelationshipUpdate(relationship, resourceFromDatabase, relationshipAssignment); } foreach (var attribute in _targetedFields.Attributes) @@ -229,7 +229,8 @@ public virtual async Task DeleteAsync(TId id) await SaveChangesAsync(); } - public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) + /// + public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); @@ -245,7 +246,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection< if (newRightResources.Count != existingRightResources.Count) { - await AssignValueToRelationship(relationship, primaryResource, newRightResources); + await ProcessRelationshipUpdate(relationship, primaryResource, newRightResources); await SaveChangesAsync(); } } @@ -268,49 +269,58 @@ private ICollection GetResourcesToAssignForRemoveFromToManyRelati return newRightResources; } - private TResource CreatePrimaryResourceWithAssignedId(TId id) + private async Task SaveChangesAsync() { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - return resource; + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new DataStoreUpdateException(exception); + } } - private void FlushFromCache(TResource resource) + private async Task ProcessRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { - _traceWriter.LogMethodStart(new {resource}); - - // TODO: Check if this change can be reverted (use GetTrackedIdentifiable). - var trackedResource = _dbContext.GetTrackedOrAttach(resource); - _dbContext.Entry(trackedResource).State = EntityState.Detached; + // Ensures the new relationship assignment will not result in entities being tracked more than once. + var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + + if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) + { + var entityEntry = _dbContext.Entry(trackedValueToAssign); + var inversePropertyName = relationship.InverseNavigationProperty.Name; + + await entityEntry.Reference(inversePropertyName).LoadAsync(); + } + + if (HasForeignKeyAtLeftSide(relationship) && trackedValueToAssign == null) + { + PrepareChangeTrackerForNullAssignment(relationship, leftResource); + } + + relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } - - private void DetachRelationships(TResource resource) + + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) { - foreach (var relationship in _targetedFields.Relationships) + if (relationship is HasOneAttribute) { - var rightValue = relationship.GetValue(resource); + var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + var navigationMetadata = entityType.FindNavigation(relationship.Property.Name); + + return navigationMetadata.IsDependentToPrincipal(); + } - if (rightValue is IEnumerable rightResources) - { - foreach (var rightResource in rightResources) - { - _dbContext.Entry(rightResource).State = EntityState.Detached; - } + return false; + } - // Detaching to-many relationships is not sufficient to - // trigger a full reload of relationships: the navigation - // property actually needs to be nulled out, otherwise - // EF Core will still add duplicate instances to the collection. + private TResource CreatePrimaryResourceWithAssignedId(TId id) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; - // TODO: Ensure that a test exists for this. Commenting out the next line still makes all tests succeed. - relationship.SetValue(resource, null, _resourceFactory); - } - else if (rightValue != null) - { - _dbContext.Entry(rightValue).State = EntityState.Detached; - } - } + return resource; } /// @@ -338,6 +348,15 @@ protected async Task LoadRelationship(RelationshipAttribute relationship, TResou } } + private void FlushFromCache(IIdentifiable resource) + { + _traceWriter.LogMethodStart(new {resource}); + + // TODO: Check if this change can be reverted (use GetTrackedIdentifiable). + var trackedResource = _dbContext.GetTrackedOrAttach(resource); + _dbContext.Entry(trackedResource).State = EntityState.Detached; + } + private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) { EntityEntry entityEntry = _dbContext.Entry(resource); @@ -362,14 +381,11 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute } /// - /// Loads the inverse of a one-to-one relationship, to support an implicit remove. This prevents a foreign key constraint from being violated. /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. /// - // TODO: Rename, this is no longer specific to one-to-one and does not affect inverse. - private async Task LoadInverseForOneToOneRelationship(RelationshipAttribute relationship, object resource) + private bool ShouldLoadInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) { - var entityEntry = _dbContext.Entry(resource); - await entityEntry.Reference(relationship.InverseNavigationProperty.Name).LoadAsync(); + return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); } private bool IsOneToOneRelationship(RelationshipAttribute relationship) @@ -383,43 +399,54 @@ private bool IsOneToOneRelationship(RelationshipAttribute relationship) return false; } - private async Task AssignValueToRelationship(RelationshipAttribute relationship, TResource leftResource, - object valueToAssign) + /// + /// If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to + /// set it to null by just assigning null to the navigation property and marking the navigation property as modified. + /// Instead, when marking it modified, it will mark the pre-existing foreign key value as modified but without nulling its value. + /// One way to work around this is by loading the relationship before nulling it. Another approach as done in this method is + /// tricking the change tracker into recognising the null assignment by first assigning a placeholder entity to the navigation property, and then + /// nulling it out. + /// + private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relationship, TResource leftResource) { - // Ensures the new relationship assignment will not result in entities being tracked more than once. - var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - if (trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship)) - { - await LoadInverseForOneToOneRelationship(relationship, trackedValueToAssign); - } + var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); + + // When assigning an entity to a navigation property, it will be assigned. This fails when the placeholder has + // nullable primary key(s) that have a null reference. + EnsurePlaceholderIsAttachable(placeholderRightResource); + + relationship.SetValue(leftResource, placeholderRightResource, _resourceFactory); + _dbContext.Entry(leftResource).DetectChanges(); - if (HasSingleForeignKeyAtLeftSide(relationship)) - { - var foreignKeyProperty = GetForeignKeyProperties((HasOneAttribute)relationship).First(); - SetValueThroughForeignKeyProperty(foreignKeyProperty, leftResource, valueToAssign); - } - else + _dbContext.Entry(placeholderRightResource).State = EntityState.Detached; + } + + private void EnsurePlaceholderIsAttachable(object entity) + { + var primaryKey = _dbContext.Entry(entity).Metadata.FindPrimaryKey(); + if (primaryKey != null) { - relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); - if (trackedValueToAssign == null) + foreach (var propertyMeta in primaryKey.Properties) { - var entry = GetNavigationEntryForRelationship(relationship, leftResource); - entry.IsModified = true; + var propertyInfo = propertyMeta.PropertyInfo; + object propertyValue = null; + + if (propertyInfo.PropertyType == typeof(string)) + { + propertyValue = ""; + } + else if (Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null) + { + propertyValue = propertyInfo.PropertyType.GetGenericArguments()[0]; + } + + if (propertyValue != null) + { + propertyInfo.SetValue(entity, propertyValue); + } } } } - - private void SetValueThroughForeignKeyProperty(IProperty foreignKeyProperty, TResource leftResource, object valueToAssign) - { - var rightResourceId = valueToAssign is IIdentifiable rightResource - ? rightResource.GetTypedId() - : null; - - // https://stackoverflow.com/questions/10257360/how-to-update-not-every-fields-of-an-object-using-entity-framework-and-entitysta - var entityEntry = _dbContext.Entry(leftResource); - entityEntry.Property(foreignKeyProperty.Name).CurrentValue = rightResourceId; - entityEntry.Property(foreignKeyProperty.Name).IsModified = true; - } private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) { @@ -450,48 +477,31 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private bool HasSingleForeignKeyAtLeftSide(RelationshipAttribute relationship) - { - if (relationship is HasOneAttribute hasOneRelationship) - { - var foreignKeyProperties = GetForeignKeyProperties(hasOneRelationship); - var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); - var hasSingleForeignKey = foreignKeyProperties.Count == 1; - return hasForeignKeyOnLeftSide && hasSingleForeignKey; - } - - return false; - } - - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) + private void DetachRelationships(TResource resource) { - if (relationship is HasOneAttribute hasOneRelationship) + foreach (var relationship in _targetedFields.Relationships) { - var foreignKeyProperties = GetForeignKeyProperties(hasOneRelationship); - var hasForeignKeyOnLeftSide = foreignKeyProperties.First().DeclaringType.ClrType == typeof(TResource); - return hasForeignKeyOnLeftSide; - } + var rightValue = relationship.GetValue(resource); - return false; - } + if (rightValue is IEnumerable rightResources) + { + foreach (var rightResource in rightResources) + { + _dbContext.Entry(rightResource).State = EntityState.Detached; + } - private IReadOnlyList GetForeignKeyProperties(HasOneAttribute relationship) - { - var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); - var foreignKeyMetadata = entityType.FindNavigation(relationship.Property.Name).ForeignKey; - - return foreignKeyMetadata.Properties; - } + // Detaching to-many relationships is not sufficient to + // trigger a full reload of relationships: the navigation + // property actually needs to be nulled out, otherwise + // EF Core will still add duplicate instances to the collection. - private async Task SaveChangesAsync() - { - try - { - await _dbContext.SaveChangesAsync(); - } - catch (DbUpdateException exception) - { - throw new DataStoreUpdateException(exception); + // TODO: Ensure that a test exists for this. Commenting out the next line still makes all tests succeed. + relationship.SetValue(resource, null, _resourceFactory); + } + else if (rightValue != null) + { + _dbContext.Entry(rightValue).State = EntityState.Detached; + } } } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index a99b0967a6..3fb96dfa7b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -38,7 +38,7 @@ public TResource CreateInstance() where TResource : IIdentifiable return identifiable; } - private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) + private object InnerCreateInstance(Type type, IServiceProvider serviceProvider) { bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index ca18e1e1a3..873a94e8c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -366,6 +366,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_Set_ToOne_Relationship_By_Patching_Resource() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + var person = _personFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(todoItem, person); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + id = todoItem.StringId, + type = "todoItems", + relationships = new + { + owner = new + { + data = new { type = "people", id = person.StringId} + } + } + } + }; + + var route = "/api/v1/todoItems/" + todoItem.StringId; + + // Act + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.Owner) + .Where(item => item.Id == todoItem.Id) + .FirstAsync(); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(person.Id); + }); + } + [Fact] public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() { From 7efe790b3f0eb5b2a12c5b890258e47719d4dc18 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 19 Oct 2020 19:11:35 +0200 Subject: [PATCH 090/240] chore: worked through todos --- .../Repositories/DbContextExtensions.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 31 +++---------------- .../Services/JsonApiResourceService.cs | 3 +- .../Acceptance/InjectableResourceTests.cs | 2 ++ .../Acceptance/Spec/UpdatingDataTests.cs | 1 + 5 files changed, 9 insertions(+), 30 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 3f45f08bd8..ca8b0d5524 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -24,7 +24,7 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti return trackedIdentifiable; } - private static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 8cac9e2267..d4d66b5526 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -136,9 +136,7 @@ public virtual async Task CreateAsync(TResource resource) _dbContext.Set().Add(resource); await SaveChangesAsync(); - - FlushFromCache(resource); - + // This ensures relationships get reloaded from the database if they have // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343. DetachRelationships(resource); @@ -171,6 +169,7 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI } await ProcessRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); + await SaveChangesAsync(); } @@ -181,20 +180,9 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); - - // TODO: I believe the comment below does not apply here (anymore). The calling resource service always fetches the entire record. - // And commenting out the line below still keeps all tests green. - // Does this comment maybe apply to SetRelationshipAsync()? - - // Maurits: We tried moving the update logic to the repo without success. Now that we're keeping - // it this (i.e. service doing a repo.GetAsync and then calling repo.UpdateAsync), I think it is good to - // keep it a repo responsibility to make sure that the provided database resource is actually present in the change tracker - // because there is no guarantee it is. - // A database entity might not be tracked if it was retrieved through projection. resourceFromDatabase = (TResource)_dbContext.GetTrackedOrAttach(resourceFromDatabase); - // TODO: Code inside this loop is very similar to SetRelationshipAsync, we should consider to factor this out into a shared method. foreach (var relationship in _targetedFields.Relationships) { if (!HasForeignKeyAtLeftSide(relationship)) @@ -214,8 +202,6 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r await SaveChangesAsync(); FlushFromCache(resourceFromDatabase); - - // TODO: Should we call DetachRelationships here, similar to Create? } /// @@ -351,9 +337,8 @@ protected async Task LoadRelationship(RelationshipAttribute relationship, TResou private void FlushFromCache(IIdentifiable resource) { _traceWriter.LogMethodStart(new {resource}); - - // TODO: Check if this change can be reverted (use GetTrackedIdentifiable). - var trackedResource = _dbContext.GetTrackedOrAttach(resource); + + var trackedResource = _dbContext.GetTrackedIdentifiable(resource); _dbContext.Entry(trackedResource).State = EntityState.Detached; } @@ -489,14 +474,6 @@ private void DetachRelationships(TResource resource) { _dbContext.Entry(rightResource).State = EntityState.Detached; } - - // Detaching to-many relationships is not sufficient to - // trigger a full reload of relationships: the navigation - // property actually needs to be nulled out, otherwise - // EF Core will still add duplicate instances to the collection. - - // TODO: Ensure that a test exists for this. Commenting out the next line still makes all tests succeed. - relationship.SetValue(resource, null, _resourceFactory); } else if (rightValue != null) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2a349bda43..753721e221 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -254,8 +254,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); // TODO: optimization; if TargetedFields.Attributes.Length = 0, we can do TopFieldSelection.OnlyIdAttribute instead of AllAttributes. - // This is a problem though with implicit change tracking: implicit changes can not be tracked. Do we think this is important? - //var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; + // var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; var fieldsToSelect = TopFieldSelection.AllAttributes; TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index df681b6e01..e0d2c94ebd 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -197,6 +197,8 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() } // TODO: We agreed this test needs to be refactored, not disabled. + // Maurits: I still don't think refactoring makes sense in this case. See JsonApiResourceService.DeleteAsync(TId). + // The reason why it fails is because we do a projection there as a default part of handling that pipeline. [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] public async Task Fail_When_Deleting_Missing_Passport() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 7266c5d007..45fb5278f1 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -382,6 +382,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: this test is flakey. [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() { From 44f454b59179e21740bba7559987ff37920a6377 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 19 Oct 2020 21:56:21 +0200 Subject: [PATCH 091/240] workaround for https://github.com/dotnet/efcore/issues/20502 --- .../Startups/Startup.cs | 4 +- .../EntityFrameworkCoreRepository.cs | 10 +++- .../MemoryLeakDetectionBugRewriter.cs | 59 +++++++++++++++++++ .../Resources/IResourceFactory.cs | 3 +- .../Resources/ResourceFactory.cs | 34 +++++++---- .../Services/JsonApiResourceService.cs | 10 ++-- src/JsonApiDotNetCore/TypeHelper.cs | 37 ------------ .../Acceptance/InjectableResourceTests.cs | 9 +-- .../Acceptance/Spec/UpdatingDataTests.cs | 2 +- 9 files changed, 101 insertions(+), 67 deletions(-) create mode 100644 src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 85116981ba..628e566a58 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -35,7 +35,9 @@ public override void ConfigureServices(IServiceCollection services) { options.EnableSensitiveDataLogging(); options.UseNpgsql(_connectionString, innerOptions => innerOptions.SetPostgresVersion(new Version(9, 6))); - }, ServiceLifetime.Transient); + }, + // TODO: Remove ServiceLifetime.Transient, after all integration tests have been converted to use IntegrationTestContext. + ServiceLifetime.Transient); services.AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d4d66b5526..26045d746e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -96,6 +96,12 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) _traceWriter.LogMethodStart(new {layer}); if (layer == null) throw new ArgumentNullException(nameof(layer)); + if (EntityFrameworkCoreSupport.Version.Major < 5) + { + var writer = new MemoryLeakDetectionBugRewriter(); + layer = writer.Rewrite(layer); + } + IQueryable source = GetAll(); var queryableHandlers = _constraintProviders @@ -137,6 +143,8 @@ public virtual async Task CreateAsync(TResource resource) _dbContext.Set().Add(resource); await SaveChangesAsync(); + FlushFromCache(resource); + // This ensures relationships get reloaded from the database if they have // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343. DetachRelationships(resource); @@ -336,8 +344,6 @@ protected async Task LoadRelationship(RelationshipAttribute relationship, TResou private void FlushFromCache(IIdentifiable resource) { - _traceWriter.LogMethodStart(new {resource}); - var trackedResource = _dbContext.GetTrackedIdentifiable(resource); _dbContext.Entry(trackedResource).State = EntityState.Detached; } diff --git a/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs b/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs new file mode 100644 index 0000000000..bf26979ad7 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Removes projections from a when its resource type uses injected parameters, + /// as a workaround for EF Core bug https://github.com/dotnet/efcore/issues/20502, which exists in versions below v5. + /// + /// + /// Note that by using this workaround, nested filtering, paging and sorting all remain broken in EF Core 3.1 when using injected parameters in resources. + /// But at least it enables simple top-level queries to succeed without an exception. + /// + public sealed class MemoryLeakDetectionBugRewriter + { + public QueryLayer Rewrite(QueryLayer queryLayer) + { + if (queryLayer == null) throw new ArgumentNullException(nameof(queryLayer)); + + return RewriteLayer(queryLayer); + } + + private QueryLayer RewriteLayer(QueryLayer queryLayer) + { + if (queryLayer != null) + { + queryLayer.Projection = RewriteProjection(queryLayer.Projection, queryLayer.ResourceContext); + } + + return queryLayer; + } + + private IDictionary RewriteProjection(IDictionary projection, ResourceContext resourceContext) + { + if (projection == null || projection.Count == 0) + { + return projection; + } + + var newProjection = new Dictionary(); + foreach (var (field, layer) in projection) + { + var newLayer = RewriteLayer(layer); + newProjection.Add(field, newLayer); + } + + if (!ResourceFactory.HasSingleConstructorWithoutParameters(resourceContext.ResourceType)) + { + return null; + } + + return newProjection; + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 80d4b82af4..57d6faac4f 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -16,7 +16,8 @@ public interface IResourceFactory /// /// Creates a new resource object instance. /// - public TResource CreateInstance() where TResource : IIdentifiable; + public TResource CreateInstance() + where TResource : IIdentifiable; /// /// Returns an expression tree that represents creating a new resource object instance. diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 3fb96dfa7b..ac74755280 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -4,7 +4,6 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Repositories; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources @@ -31,14 +30,13 @@ public object CreateInstance(Type resourceType) } /// - public TResource CreateInstance() where TResource : IIdentifiable + public TResource CreateInstance() + where TResource : IIdentifiable { - var identifiable = (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); - - return identifiable; + return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); } - private object InnerCreateInstance(Type type, IServiceProvider serviceProvider) + private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) { bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); @@ -77,10 +75,8 @@ public NewExpression CreateNewExpression(Type resourceType) object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); - var argumentExpression = EntityFrameworkCoreSupport.Version.Major >= 5 - // Workaround for https://github.com/dotnet/efcore/issues/20502 to not fail on injected DbContext in EF Core 5. - ? CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()) - : Expression.Constant(constructorArgument); + var argumentExpression = + CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()); constructorArguments.Add(argumentExpression); } @@ -108,7 +104,7 @@ private static Expression CreateTupleAccessExpressionForConstant(object value, T return Expression.Property(tupleCreateCall, "Item1"); } - private static bool HasSingleConstructorWithoutParameters(Type type) + internal static bool HasSingleConstructorWithoutParameters(Type type) { ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); @@ -123,8 +119,20 @@ private static ConstructorInfo GetLongestConstructor(Type type) { throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); } - - var bestMatch = TypeHelper.GetLongestConstructor(constructors); + + ConstructorInfo bestMatch = constructors[0]; + int maxParameterLength = constructors[0].GetParameters().Length; + + for (int index = 1; index < constructors.Length; index++) + { + var constructor = constructors[index]; + int length = constructor.GetParameters().Length; + if (length > maxParameterLength) + { + bestMatch = constructor; + maxParameterLength = length; + } + } return bestMatch; } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 753721e221..9cab4be5ea 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -252,12 +252,10 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR { _traceWriter.LogMethodStart(new {id, resourceFromRequest}); if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); - - // TODO: optimization; if TargetedFields.Attributes.Length = 0, we can do TopFieldSelection.OnlyIdAttribute instead of AllAttributes. - // var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; - var fieldsToSelect = TopFieldSelection.AllAttributes; + + var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); - + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -265,7 +263,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR { resourceFromRequest = _hookExecutor.BeforeUpdate(ToList(resourceFromRequest), ResourcePipeline.Patch).Single(); } - + try { await _repository.UpdateAsync(resourceFromRequest, resourceFromDatabase); diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 8e457a9296..13782002d6 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -6,7 +6,6 @@ using System.Reflection; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore { @@ -350,41 +349,5 @@ public static bool IsOrImplementsInterface(Type source, Type interfaceType) return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); } - - internal static ConstructorInfo GetLongestConstructor(ConstructorInfo[] constructors) - { - ConstructorInfo bestMatch = constructors[0]; - int maxParameterLength = constructors[0].GetParameters().Length; - - for (int index = 1; index < constructors.Length; index++) - { - var constructor = constructors[index]; - int length = constructor.GetParameters().Length; - if (length > maxParameterLength) - { - bestMatch = constructor; - maxParameterLength = length; - } - } - - return bestMatch; - } - - internal static bool ConstructorDependsOnDbContext(Type resourceType) - { - // TODO: Rewrite existing test(s) so this can be reverted. - - var constructors = resourceType.GetConstructors().Where(c => !c.IsStatic).ToArray(); - if (constructors.Any()) - { - var dbContextType = typeof(DbContext); - var constructor = GetLongestConstructor(constructors); - - return constructor.GetParameters().Any(p => dbContextType.IsAssignableFrom(p.ParameterType)); - } - - return false; - - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index e0d2c94ebd..ec519a898b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -107,7 +107,7 @@ public async Task Can_Get_Passports() } } - [Fact(Skip = "Requires fix for https://github.com/dotnet/efcore/issues/20502")] + [Fact] public async Task Can_Get_Passports_With_Filter() { // Arrange @@ -147,7 +147,7 @@ public async Task Can_Get_Passports_With_Filter() Assert.Equal("Joe", document.Included[0].Attributes["firstName"]); } - [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] + [Fact] public async Task Can_Get_Passports_With_Sparse_Fieldset() { // Arrange @@ -196,10 +196,7 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() resource => resource.Attributes.ContainsKey("lastName")); } - // TODO: We agreed this test needs to be refactored, not disabled. - // Maurits: I still don't think refactoring makes sense in this case. See JsonApiResourceService.DeleteAsync(TId). - // The reason why it fails is because we do a projection there as a default part of handling that pipeline. - [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] + [Fact] public async Task Fail_When_Deleting_Missing_Passport() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 45fb5278f1..91aad7cdd4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -382,7 +382,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: this test is flakey. + // TODO: this test is flaky. [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() { From a8b913deb1431cb2a4a08a080e3a3b171f8f5033 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 19 Oct 2020 23:01:00 +0200 Subject: [PATCH 092/240] fix: rename --- .../Repositories/EntityFrameworkCoreRepository.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 26045d746e..d50328421c 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -143,6 +143,7 @@ public virtual async Task CreateAsync(TResource resource) _dbContext.Set().Add(resource); await SaveChangesAsync(); + // Todo: why was this reverted? FlushFromCache(resource); // This ensures relationships get reloaded from the database if they have @@ -404,7 +405,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio // When assigning an entity to a navigation property, it will be assigned. This fails when the placeholder has // nullable primary key(s) that have a null reference. - EnsurePlaceholderIsAttachable(placeholderRightResource); + EnsureNoNullPrimaryKeys(placeholderRightResource); relationship.SetValue(leftResource, placeholderRightResource, _resourceFactory); _dbContext.Entry(leftResource).DetectChanges(); @@ -412,7 +413,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio _dbContext.Entry(placeholderRightResource).State = EntityState.Detached; } - private void EnsurePlaceholderIsAttachable(object entity) + private void EnsureNoNullPrimaryKeys(object entity) { var primaryKey = _dbContext.Entry(entity).Metadata.FindPrimaryKey(); if (primaryKey != null) From 967b97d00fe9fb15f5c137f72254fa40ba4636f3 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 19 Oct 2020 23:02:03 +0200 Subject: [PATCH 093/240] Comment --- .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d50328421c..66eae6e3c8 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -393,8 +393,8 @@ private bool IsOneToOneRelationship(RelationshipAttribute relationship) /// /// If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to - /// set it to null by just assigning null to the navigation property and marking the navigation property as modified. - /// Instead, when marking it modified, it will mark the pre-existing foreign key value as modified but without nulling its value. + /// set it to null by just assigning null to the navigation property and marking it as modified. + /// Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too but without nulling its value. /// One way to work around this is by loading the relationship before nulling it. Another approach as done in this method is /// tricking the change tracker into recognising the null assignment by first assigning a placeholder entity to the navigation property, and then /// nulling it out. From a4e52de5a4bca7e1e66d506da3e01af0a46db24d Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 08:09:36 +0200 Subject: [PATCH 094/240] chore: refactor --- .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 66eae6e3c8..31d7bba8e6 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -403,8 +403,8 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio { var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); - // When assigning an entity to a navigation property, it will be assigned. This fails when the placeholder has - // nullable primary key(s) that have a null reference. + // When assigning an related entity to a navigation property, it will be attached to change tracker. This fails + // when that entity has null reference(s) for its primary key(s). EnsureNoNullPrimaryKeys(placeholderRightResource); relationship.SetValue(leftResource, placeholderRightResource, _resourceFactory); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 9cab4be5ea..a4ebbb46b2 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -381,13 +381,13 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } } - private async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + protected async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) { var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); primaryLayer.Sort = null; primaryLayer.Pagination = null; primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); - + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); @@ -552,7 +552,7 @@ private List ToList(TResource resource) return new List { resource }; } - private enum TopFieldSelection + protected enum TopFieldSelection { AllAttributes, OnlyIdAttribute, From ff564ecae97a9453ae76f713f73db16c05dc6e1b Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 08:55:51 +0200 Subject: [PATCH 095/240] chore: refactor using EnsureCompleteReplacement, add two todos --- .../EntityFrameworkCoreRepository.cs | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 31d7bba8e6..a821a4a8e6 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -171,12 +171,8 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI var relationship = _targetedFields.Relationships.Single(); TResource primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - - if (!HasForeignKeyAtLeftSide(relationship)) - { - await LoadRelationship(relationship, primaryResource); - } - + + await EnsureCompleteReplacement(relationship, primaryResource); await ProcessRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); @@ -194,10 +190,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (var relationship in _targetedFields.Relationships) { - if (!HasForeignKeyAtLeftSide(relationship)) - { - await LoadRelationship(relationship, resourceFromDatabase); - } + await EnsureCompleteReplacement(relationship, resourceFromDatabase); var relationshipAssignment = relationship.GetValue(resourceFromRequest); await ProcessRelationshipUpdate(relationship, resourceFromDatabase, relationshipAssignment); @@ -233,13 +226,16 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol var relationship = _targetedFields.Relationships.Single(); var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - await LoadRelationship(relationship, primaryResource); + await EnsureCompleteReplacement(relationship, primaryResource); var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); - var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, - secondaryResourceIds.Select(r => r.StringId)); + // todo: consider reverting like done below. I don't think the commented out version is more readable. + var newRightResources = existingRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToList(); + // var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources,secondaryResourceIds.Select(r => r.StringId)); - if (newRightResources.Count != existingRightResources.Count) + if (newRightResources.Count < existingRightResources.Count); + // todo: + // if (newRightResources.Count != existingRightResources.Count) { await ProcessRelationshipUpdate(relationship, primaryResource, newRightResources); await SaveChangesAsync(); @@ -285,7 +281,6 @@ private async Task ProcessRelationshipUpdate(RelationshipAttribute relationship, { var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; - await entityEntry.Reference(inversePropertyName).LoadAsync(); } @@ -296,7 +291,7 @@ private async Task ProcessRelationshipUpdate(RelationshipAttribute relationship, relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); } - + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) { if (relationship is HasOneAttribute) @@ -317,28 +312,28 @@ private TResource CreatePrimaryResourceWithAssignedId(TId id) return resource; } - + /// - /// Before assigning new relationship values, we need to attach the current database values - /// of the relationship to the DbContext, otherwise it will not perform a complete-replace, - /// which is required for one-to-many and many-to-many. - /// + /// Prepares a relationship for complete replacement. + /// + /// /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. /// If we want to update this set to `t3` and `t4`, simply assigning /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, /// after which the reassignment `p1.todoItems = [t3, t4]` will actually - /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. - /// - /// - protected async Task LoadRelationship(RelationshipAttribute relationship, TResource resource) - { + /// make EF Core perform a complete replacement. This method does the loading of `[t1, t2]`. + /// + protected async Task EnsureCompleteReplacement(RelationshipAttribute relationship, TResource resource) + { + _traceWriter.LogMethodStart(new {relationship, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); - if (navigationEntry != null) + // If the left resource is the dependent side of the relationship, complete replacement is already guaranteed. + if (!HasForeignKeyAtLeftSide(relationship)) { + var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); await navigationEntry.LoadAsync(); } } From a0fe2dbeb9dc68dd480686759af2ad5fb5d261cf Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 09:02:33 +0200 Subject: [PATCH 096/240] chore: add todo --- src/JsonApiDotNetCore/Resources/ResourceFactory.cs | 7 +++++-- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index ac74755280..97dc3e9dd3 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -12,12 +12,15 @@ namespace JsonApiDotNetCore.Resources internal sealed class ResourceFactory : IResourceFactory { private readonly IServiceProvider _serviceProvider; - + // todo: consider converting static methods to instance methods. public ResourceFactory(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); } + // todo: this is confusing. CreateInstance() works for iidentifiables, whereas CreateInstance(Type) works for any. + // These methods suggest they do the same but just allow for a non-generic vs generic way of doing it. Also because the comments are the same. + // This method is being used for creating join table entities, which most of the time aren't resources, so this is technically not correct. /// public object CreateInstance(Type resourceType) { @@ -28,7 +31,7 @@ public object CreateInstance(Type resourceType) return InnerCreateInstance(resourceType, _serviceProvider); } - + /// public TResource CreateInstance() where TResource : IIdentifiable diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index a4ebbb46b2..5e3bf40fb1 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -506,7 +506,7 @@ private async Task> GetSecondaryResourceStringIdsAsync(Type return resources.Select(resource => resource.StringId).ToArray(); } - private static FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext) + private FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext) { var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); var idChain = new ResourceFieldChainExpression(idAttribute); From 44fd448e3c1a37c4fde376645e389b278ae367ec Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 10:31:16 +0200 Subject: [PATCH 097/240] renames --- .../Controllers/BaseJsonApiController.cs | 4 ++-- .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- .../Services/JsonApiResourceService.cs | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 70ccfaa718..167bbf1b42 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -143,9 +143,9 @@ public virtual async Task GetRelationshipAsync(TId id, string rel if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationshipAssignment = await _getRelationship.GetRelationshipAsync(id, relationshipName); + var rightResources = await _getRelationship.GetRelationshipAsync(id, relationshipName); - return Ok(relationshipAssignment); + return Ok(rightResources); } /// diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a821a4a8e6..a635fe5e78 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -192,8 +192,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r { await EnsureCompleteReplacement(relationship, resourceFromDatabase); - var relationshipAssignment = relationship.GetValue(resourceFromRequest); - await ProcessRelationshipUpdate(relationship, resourceFromDatabase, relationshipAssignment); + var rightResources = relationship.GetValue(resourceFromRequest); + await ProcessRelationshipUpdate(relationship, resourceFromDatabase, rightResources); } foreach (var attribute in _targetedFields.Attributes) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 5e3bf40fb1..951cbe0a3f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -206,7 +206,7 @@ public virtual async Task CreateAsync(TResource resource) } catch (DataStoreUpdateException) { - await AssertResourcesInRelationshipAssignmentsExistAsync(_targetedFields.Relationships, resource); + await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resource); throw; } @@ -241,7 +241,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); - await AssertResourcesInRelationshipAssignmentExistAsync(_request.Relationship, secondaryResourceIds); + await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } } @@ -270,7 +270,7 @@ public virtual async Task UpdateAsync(TId id, TResource resourceFromR } catch (DataStoreUpdateException) { - await AssertResourcesInRelationshipAssignmentsExistAsync(_targetedFields.Relationships, resourceFromRequest); + await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); throw; } @@ -316,7 +316,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertPrimaryResourceExists(primaryResource); } - await AssertResourcesInRelationshipAssignmentExistAsync(_request.Relationship, secondaryResourceIds); + await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } @@ -422,7 +422,7 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); } - private async Task AssertResourcesInRelationshipAssignmentsExistAsync(IEnumerable relationships, TResource leftResource) + private async Task AssertRightResourcesInRelationshipsExistAsync(IEnumerable relationships, TResource leftResource) { var missingResources = new List(); @@ -441,7 +441,7 @@ private async Task AssertResourcesInRelationshipAssignmentsExistAsync(IEnumerabl } } - private async Task AssertResourcesInRelationshipAssignmentExistAsync(RelationshipAttribute relationship, object secondaryResourceIds) + private async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, object secondaryResourceIds) { ICollection rightResources = ExtractResources(secondaryResourceIds); From eb0bcd9efcd8a4fa6e5a6d312717cdb12862f827 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 11:47:57 +0200 Subject: [PATCH 098/240] changes on resource factory --- .../Resources/Annotations/HasManyThroughAttribute.cs | 5 ++++- src/JsonApiDotNetCore/Resources/IResourceFactory.cs | 2 +- src/JsonApiDotNetCore/Resources/ResourceFactory.cs | 12 ++++-------- .../Serialization/BaseDeserializer.cs | 6 +++--- .../Client/Internal/ResponseDeserializer.cs | 2 +- src/JsonApiDotNetCore/TypeHelper.cs | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 16d8cd1075..01af6a29b9 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -137,7 +137,10 @@ public override void SetValue(object resource, object newValue, IResourceFactory List throughResources = new List(); foreach (IIdentifiable identifiable in (IEnumerable)newValue) { - object throughResource = resourceFactory.CreateInstance(ThroughType); + var throughResource = TypeHelper.IsOrImplementsInterface(ThroughType, typeof(IIdentifiable)) + ? resourceFactory.CreateInstance(ThroughType) + : TypeHelper.CreateInstance(ThroughType); + LeftProperty.SetValue(throughResource, resource); RightProperty.SetValue(throughResource, identifiable); throughResources.Add(throughResource); diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 57d6faac4f..38a25ad996 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -11,7 +11,7 @@ public interface IResourceFactory /// /// Creates a new resource object instance. /// - public object CreateInstance(Type resourceType); + public IIdentifiable CreateInstance(Type resourceType); /// /// Creates a new resource object instance. diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 97dc3e9dd3..95e4ecdea3 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -using JsonApiDotNetCore.Repositories; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources @@ -18,11 +17,8 @@ public ResourceFactory(IServiceProvider serviceProvider) _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); } - // todo: this is confusing. CreateInstance() works for iidentifiables, whereas CreateInstance(Type) works for any. - // These methods suggest they do the same but just allow for a non-generic vs generic way of doing it. Also because the comments are the same. - // This method is being used for creating join table entities, which most of the time aren't resources, so this is technically not correct. /// - public object CreateInstance(Type resourceType) + public IIdentifiable CreateInstance(Type resourceType) { if (resourceType == null) { @@ -39,15 +35,15 @@ public TResource CreateInstance() return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); } - private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) + private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider serviceProvider) { bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); try { return hasSingleConstructorWithoutParameters - ? Activator.CreateInstance(type) - : ActivatorUtilities.CreateInstance(serviceProvider, type); + ? (IIdentifiable)Activator.CreateInstance(type) + : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); } catch (Exception exception) { diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 61d6ec1408..3c48e49a70 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -146,7 +146,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); } - var resource = (IIdentifiable)ResourceFactory.CreateInstance(resourceContext.ResourceType); + var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); @@ -221,7 +221,7 @@ private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string } else { - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType); + var relatedInstance = ResourceFactory.CreateInstance(relationshipType); relatedInstance.StringId = relatedId; attr.SetValue(resource, relatedInstance, ResourceFactory); } @@ -240,7 +240,7 @@ private void SetHasManyRelationship( var relatedResources = relationshipData.ManyData.Select(rio => { var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType; - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType); + var relatedInstance = ResourceFactory.CreateInstance(relationshipType); relatedInstance.StringId = rio.Id; return relatedInstance; diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs index 90f1f35a8f..59ac58d9cf 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs @@ -94,7 +94,7 @@ private IIdentifiable ParseIncludedRelationship(ResourceIdentifierObject related throw new InvalidOperationException($"Included type '{relatedResourceIdentifier.Type}' is not a registered json:api resource."); } - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relatedResourceContext.ResourceType); + var relatedInstance = ResourceFactory.CreateInstance(relatedResourceContext.ResourceType); relatedInstance.StringId = relatedResourceIdentifier.Id; var includedResource = GetLinkedResource(relatedResourceIdentifier); diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 13782002d6..f9e028ed8b 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -286,7 +286,7 @@ public static object CreateInstance(Type type) public static object ConvertStringIdToTypedId(Type resourceType, string stringId, IResourceFactory resourceFactory) { - var tempResource = (IIdentifiable)resourceFactory.CreateInstance(resourceType); + var tempResource = resourceFactory.CreateInstance(resourceType); tempResource.StringId = stringId; return tempResource.GetTypedId(); } From a711f4f0b48fce64ad37357e54e869aa253f7e2e Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 13:28:10 +0200 Subject: [PATCH 099/240] chore: refactor proposal --- .../Repositories/EntityFrameworkCoreRepository.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a635fe5e78..b12a17d0cf 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -230,12 +230,13 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); // todo: consider reverting like done below. I don't think the commented out version is more readable. - var newRightResources = existingRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToList(); - // var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources,secondaryResourceIds.Select(r => r.StringId)); + // var newRightResources = existingRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToList(); + var newRightResources = RemoveResources(existingRightResources, secondaryResourceIds); - if (newRightResources.Count < existingRightResources.Count); // todo: // if (newRightResources.Count != existingRightResources.Count) + var hasRemovals = newRightResources.Count < existingRightResources.Count; + if (hasRemovals) { await ProcessRelationshipUpdate(relationship, primaryResource, newRightResources); await SaveChangesAsync(); @@ -252,12 +253,10 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol /// returns { 1, 2 } /// ]]> /// - private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( - IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) + private ICollection RemoveResources(IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) { var newRightResources = new HashSet(existingRightResources); - newRightResources.RemoveWhere(r => resourceIdsToRemove.Any(stringId => r.StringId == stringId)); - return newRightResources; + return newRightResources.Except(resourceIdsToRemove, IdentifiableComparer.Instance).ToList(); } private async Task SaveChangesAsync() From d2a06adf51c79fa64357227bbfe9509d10fd244d Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 13:31:05 +0200 Subject: [PATCH 100/240] chore: add comment --- .../Repositories/EntityFrameworkCoreRepository.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index b12a17d0cf..cae043b961 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -231,6 +231,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); // todo: consider reverting like done below. I don't think the commented out version is more readable. // var newRightResources = existingRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToList(); + // var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources,secondaryResourceIds.Select(r => r.StringId)); var newRightResources = RemoveResources(existingRightResources, secondaryResourceIds); // todo: @@ -253,6 +254,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol /// returns { 1, 2 } /// ]]> /// + // private ICollection GetResourcesToAssignForRemoveFromToManyRelationship(IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) private ICollection RemoveResources(IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) { var newRightResources = new HashSet(existingRightResources); From acbf66f5953890879d3dd0d1f08549830d46dfc7 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 13:38:20 +0200 Subject: [PATCH 101/240] chore: rename --- .../EntityFrameworkCoreRepository.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index cae043b961..97c0a50099 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -137,7 +137,7 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationship in _targetedFields.Relationships) { var rightValue = relationship.GetValue(resource); - await ProcessRelationshipUpdate(relationship, resource, rightValue); + await ApplyRelationshipUpdate(relationship, resource, rightValue); } _dbContext.Set().Add(resource); @@ -160,7 +160,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollecti var relationship = _targetedFields.Relationships.Single(); var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - await ProcessRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); + await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } @@ -172,8 +172,8 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI var relationship = _targetedFields.Relationships.Single(); TResource primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - await EnsureCompleteReplacement(relationship, primaryResource); - await ProcessRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); + await EnableCompleteReplacement(relationship, primaryResource); + await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } @@ -190,10 +190,10 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (var relationship in _targetedFields.Relationships) { - await EnsureCompleteReplacement(relationship, resourceFromDatabase); + await EnableCompleteReplacement(relationship, resourceFromDatabase); var rightResources = relationship.GetValue(resourceFromRequest); - await ProcessRelationshipUpdate(relationship, resourceFromDatabase, rightResources); + await ApplyRelationshipUpdate(relationship, resourceFromDatabase, rightResources); } foreach (var attribute in _targetedFields.Attributes) @@ -226,7 +226,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol var relationship = _targetedFields.Relationships.Single(); var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - await EnsureCompleteReplacement(relationship, primaryResource); + await EnableCompleteReplacement(relationship, primaryResource); var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); // todo: consider reverting like done below. I don't think the commented out version is more readable. @@ -239,7 +239,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol var hasRemovals = newRightResources.Count < existingRightResources.Count; if (hasRemovals) { - await ProcessRelationshipUpdate(relationship, primaryResource, newRightResources); + await ApplyRelationshipUpdate(relationship, primaryResource, newRightResources); await SaveChangesAsync(); } } @@ -273,7 +273,7 @@ private async Task SaveChangesAsync() } } - private async Task ProcessRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) + private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { // Ensures the new relationship assignment will not result in entities being tracked more than once. var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); @@ -325,7 +325,7 @@ private TResource CreatePrimaryResourceWithAssignedId(TId id) /// after which the reassignment `p1.todoItems = [t3, t4]` will actually /// make EF Core perform a complete replacement. This method does the loading of `[t1, t2]`. /// - protected async Task EnsureCompleteReplacement(RelationshipAttribute relationship, TResource resource) + protected async Task EnableCompleteReplacement(RelationshipAttribute relationship, TResource resource) { _traceWriter.LogMethodStart(new {relationship, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); From 3172e4126c53b10937f583db90c48443a44792b8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 14:11:49 +0200 Subject: [PATCH 102/240] comments --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 2 +- .../Services/IRemoveFromRelationshipService.cs | 2 +- .../Acceptance/Spec/DeletingDataTests.cs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 167bbf1b42..7124d8385a 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -265,7 +265,7 @@ public virtual async Task DeleteAsync(TId id) /// /// The identifier of the primary resource. /// The relationship to remove resources from. - /// The resources to remove from the relationship. + /// The set of resources to remove from the relationship. public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index 2a6cdcdb14..918162ca2e 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -16,7 +16,7 @@ public interface IRemoveFromRelationshipService where TResour /// /// The identifier of the primary resource. /// The relationship to remove resources from. - /// The resources to remove from the relationship. + /// The set of resources to remove from the relationship. Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index 3b74dd7502..3e32bebb34 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -53,5 +53,7 @@ public async Task Respond_404_If_ResourceDoesNotExist() Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.",errorDocument.Errors[0].Detail); } + + // TODO: Add test for DeleteRelationshipAsync that only deletes non-existing from the right resources in to-many relationship. } } From 764f03322b84788467c2ab17b50d10267d7ff703 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 20 Oct 2020 14:13:49 +0200 Subject: [PATCH 103/240] fix: Todo item on NoContent in PatchAsync controller method --- .../Controllers/BaseJsonApiController.cs | 8 ++++- .../Acceptance/KebabCaseFormatterTests.cs | 8 ++--- .../Acceptance/ManyToManyTests.cs | 21 ++++--------- .../Spec/UpdatingRelationshipsTests.cs | 8 ++--- .../CompositeKeys/CompositeKeyTests.cs | 10 ++----- .../ModelStateValidationTests.cs | 30 +++++++------------ .../NoModelStateValidationTests.cs | 6 ++-- 7 files changed, 35 insertions(+), 56 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 167bbf1b42..b54450f3f7 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -223,7 +223,13 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource var updated = await _update.UpdateAsync(id, resource); // TODO: json:api spec says to return 204 without body when no side-effects. See other comments on how this could be interpreted for relationships too. - return updated == null ? Ok(null) : Ok(updated); + // TODO: check tests + if (updated != null) + { + return Ok(updated); + } + + return NoContent(); } /// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index ee8742e8d4..1652f3b367 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -131,13 +131,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = serializer.Serialize(model); // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); - + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + await _testContext.RunOnDatabaseAsync(async dbContext => { var stored = await dbContext.KebabCasedModels.SingleAsync(x => x.Id == model.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index eda2dcdd4d..f4384610aa 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -309,11 +309,8 @@ public async Task Can_Update_Many_To_Many() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.Null(articleResponse); - + Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include(a => a.ArticleTags) @@ -374,11 +371,8 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.Null(articleResponse); - + Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include("ArticleTags.Tag") @@ -442,11 +436,8 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.Null(articleResponse); - + Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include(a => a.ArticleTags) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 873a94e8c5..8c4ebfe57e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -239,7 +239,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -305,7 +305,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -453,7 +453,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -507,7 +507,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 874479d294..5e94f73596 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -220,10 +220,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); - + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines @@ -282,9 +280,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 155d54c3e7..a8d44c354d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -352,12 +352,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); } [Fact] @@ -544,12 +542,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); } [Fact] @@ -656,12 +652,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); } [Fact] @@ -715,12 +709,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); } [Fact] @@ -769,12 +761,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 5fa2e6d2f7..5d9efe429b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -78,12 +78,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); } } } From 0c4d3b6fe3dc0a4359045a80f56f819bd95ccdff Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 14:42:08 +0200 Subject: [PATCH 104/240] revert: removed assertions on response body --- .../Controllers/BaseJsonApiController.cs | 8 +------- .../Acceptance/KebabCaseFormatterTests.cs | 6 ++++-- .../Acceptance/ManyToManyTests.cs | 12 ++++++++--- .../Spec/UpdatingRelationshipsTests.cs | 16 +++++++++++---- .../Acceptance/TodoItemControllerTests.cs | 4 ++++ .../CompositeKeys/CompositeKeyTests.cs | 10 +++++++--- .../ModelStateValidationTests.cs | 20 ++++++++++++++----- .../NoModelStateValidationTests.cs | 4 +++- test/NoEntityFrameworkTests/WorkItemTests.cs | 4 +--- 9 files changed, 56 insertions(+), 28 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 4c97b1c1b4..75fa84ad01 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -223,13 +223,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource var updated = await _update.UpdateAsync(id, resource); // TODO: json:api spec says to return 204 without body when no side-effects. See other comments on how this could be interpreted for relationships too. - // TODO: check tests - if (updated != null) - { - return Ok(updated); - } - - return NoContent(); + return updated == null ? (IActionResult) NoContent() : Ok(updated); } /// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index 1652f3b367..1d995bb721 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -131,11 +131,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = serializer.Serialize(model); // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var stored = await dbContext.KebabCasedModels.SingleAsync(x => x.Id == model.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index f4384610aa..4eaa83c5a5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -310,7 +310,9 @@ public async Task Can_Update_Many_To_Many() // Assert var body = await response.Content.ReadAsStringAsync(); Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - + + Assert.Empty(body); + _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include(a => a.ArticleTags) @@ -372,7 +374,9 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() // Assert var body = await response.Content.ReadAsStringAsync(); Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - + + Assert.Empty(body); + _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include("ArticleTags.Tag") @@ -437,7 +441,9 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap // Assert var body = await response.Content.ReadAsStringAsync(); Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - + + Assert.Empty(body); + _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include(a => a.ArticleTags) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 8c4ebfe57e..931d179442 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -236,11 +236,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/people/" + person2.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personsInDatabase = await dbContext.People @@ -302,11 +304,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/todoCollections/" + todoCollection.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var todoCollectionInDatabase = await dbContext.TodoItemCollections @@ -450,11 +454,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/people/" + person.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People @@ -504,11 +510,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/people/" + person2.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personsInDatabase = await dbContext.People diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index efc4d7538b..88242eb988 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -396,6 +396,10 @@ public async Task Can_Delete_TodoItem() // Assert Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Empty(body); + Assert.Null(_context.TodoItems.FirstOrDefault(t => t.Id == todoItem.Id)); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 5e94f73596..a5c67915fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -217,11 +217,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/engines/" + engine.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines @@ -277,11 +279,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/engines/" + engine.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index a8d44c354d..b80ef6e811 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -352,10 +352,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -542,10 +544,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -652,10 +656,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -709,10 +715,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -761,10 +769,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 5d9efe429b..ff3b22c520 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -78,10 +78,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } } } diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index decaf0e837..5b5a39dd28 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -144,9 +144,7 @@ await ExecuteOnDbContextAsync(async dbContext => AssertStatusCode(HttpStatusCode.NoContent, response); string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); - - Assert.Null(document); + Assert.Empty(responseBody); } private async Task ExecuteOnDbContextAsync(Func asyncAction) From 78cb9cc17fa456f3ba5ae5edf9583f86425d6996 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 14:51:38 +0200 Subject: [PATCH 105/240] revert name changes --- .../EntityFrameworkCoreRepository.cs | 26 ++++++++----------- .../Resources/ResourceFactory.cs | 2 +- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 97c0a50099..3fb4894c0d 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -229,15 +229,10 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol await EnableCompleteReplacement(relationship, primaryResource); var existingRightResources = (IReadOnlyCollection)relationship.GetValue(primaryResource); - // todo: consider reverting like done below. I don't think the commented out version is more readable. - // var newRightResources = existingRightResources.Where(i => secondaryResourceIds.All(r => r.StringId != i.StringId)).ToList(); - // var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources,secondaryResourceIds.Select(r => r.StringId)); - var newRightResources = RemoveResources(existingRightResources, secondaryResourceIds); - - // todo: - // if (newRightResources.Count != existingRightResources.Count) - var hasRemovals = newRightResources.Count < existingRightResources.Count; - if (hasRemovals) + var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, secondaryResourceIds); + + bool hasChanges = newRightResources.Count != existingRightResources.Count; + if (hasChanges) { await ApplyRelationshipUpdate(relationship, primaryResource, newRightResources); await SaveChangesAsync(); @@ -245,20 +240,21 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCol } /// - /// Removes resources from whose ID exists in . + /// Removes resources from whose ID exists in . /// /// /// /// - // private ICollection GetResourcesToAssignForRemoveFromToManyRelationship(IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) - private ICollection RemoveResources(IEnumerable existingRightResources, IEnumerable resourceIdsToRemove) + private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( + IEnumerable existingRightResources, IEnumerable resourcesToRemove) { - var newRightResources = new HashSet(existingRightResources); - return newRightResources.Except(resourceIdsToRemove, IdentifiableComparer.Instance).ToList(); + var newRightResources = new HashSet(existingRightResources, IdentifiableComparer.Instance); + newRightResources.ExceptWith(resourcesToRemove); + return newRightResources; } private async Task SaveChangesAsync() diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 95e4ecdea3..568e964c3a 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Resources internal sealed class ResourceFactory : IResourceFactory { private readonly IServiceProvider _serviceProvider; - // todo: consider converting static methods to instance methods. + public ResourceFactory(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); From dfcd28d438c52a2762f63bd680f34dbae347acb3 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 15:10:55 +0200 Subject: [PATCH 106/240] Use ISet in public signatures --- .../NoEntityFrameworkExample/Services/WorkItemService.cs | 4 ++-- .../Controllers/BaseJsonApiController.cs | 7 +++++-- .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- .../Repositories/IResourceWriteRepository.cs | 4 ++-- .../Services/IAddToRelationshipService.cs | 4 ++-- .../Services/IRemoveFromRelationshipService.cs | 2 +- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 6 ++++-- .../Extensions/IServiceCollectionExtensionsTests.cs | 8 ++++---- 8 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index b596e62a16..4bbe4fec08 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -72,12 +72,12 @@ public Task SetRelationshipAsync(int id, string relationshipName, object seconda throw new NotImplementedException(); } - public Task AddToToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) + public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) { throw new NotImplementedException(); } - public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) + public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 75fa84ad01..f8e1cb3d7a 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -188,9 +189,10 @@ public virtual async Task PostRelationshipAsync(TId id, string re { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds.ToHashSet(IdentifiableComparer.Instance)); // TODO: Silently ignore already-existing entries, causing duplicates. From json:api spec: // "If a client makes a POST request to a URL from a relationship link, the server MUST add the specified members to the relationship unless they are already present. If a given type and id is already in the relationship, the server MUST NOT add it again" @@ -270,9 +272,10 @@ public virtual async Task DeleteRelationshipAsync(TId id, string { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds.ToHashSet(IdentifiableComparer.Instance)); // TODO: Should return 204 when relationship does not exist (see json:api spec) + ensure we have a test covering this. return Ok(); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3fb4894c0d..70c8eeb3ac 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -152,7 +152,7 @@ public virtual async Task CreateAsync(TResource resource) } /// - public virtual async Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) + public virtual async Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); @@ -218,7 +218,7 @@ public virtual async Task DeleteAsync(TId id) } /// - public virtual async Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds) + public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index da1f8a5d0b..f1057d2790 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -27,7 +27,7 @@ public interface IResourceWriteRepository /// /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddToToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds); + Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds); /// /// Updates the attributes and relationships of an existing resource in the underlying data store. @@ -47,6 +47,6 @@ public interface IResourceWriteRepository /// /// Removes resources from a to-many relationship in the underlying data store. /// - Task RemoveFromToManyRelationshipAsync(TId id, IReadOnlyCollection secondaryResourceIds); + Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index 43a8ce5fe4..4d235cffcb 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -16,7 +16,7 @@ public interface IAddToRelationshipService where TResource : /// /// The identifier of the primary resource. /// The relationship to add resources to. - /// The resources to add to the relationship. - Task AddToToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds); + /// The set of resources to add to the relationship. + Task AddToToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index 918162ca2e..bbac022341 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -17,6 +17,6 @@ public interface IRemoveFromRelationshipService where TResour /// The identifier of the primary resource. /// The relationship to remove resources from. /// The set of resources to remove from the relationship. - Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds); + Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 951cbe0a3f..1a911cb36f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -222,10 +222,11 @@ public virtual async Task CreateAsync(TResource resource) } /// - public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds) + public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds) { _traceWriter.LogMethodStart(new { id, secondaryResourceIds }); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); @@ -360,10 +361,11 @@ public virtual async Task DeleteAsync(TId id) } /// - public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, IReadOnlyCollection secondaryResourceIds) + public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 3b4a904667..901b8871af 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -167,8 +167,8 @@ private class IntResourceService : IResourceService public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource resourceFromRequest) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); - public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -181,8 +181,8 @@ private class GuidResourceService : IResourceService public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource resourceFromRequest) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); - public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, IReadOnlyCollection secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } From 6d72a0c1af64159f470871918de149f53d6d2ff9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 20 Oct 2020 17:08:16 +0200 Subject: [PATCH 107/240] made public --- src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 63c0d6dd46..3dee7f52c2 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Resources /// /// Compares `IIdentifiable` instances with each other based on StringId. /// - internal sealed class IdentifiableComparer : IEqualityComparer + public sealed class IdentifiableComparer : IEqualityComparer { public static readonly IdentifiableComparer Instance = new IdentifiableComparer(); From 9e81c918db9e9d88266852425362c8aeaf1911d0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 21 Oct 2020 15:59:38 +0200 Subject: [PATCH 108/240] docs update --- src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index fded94294a..6fd6a62b39 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -32,7 +32,7 @@ public interface IJsonApiOptions bool IncludeExceptionStackTraceInErrors { get; } /// - /// Use relative links for all resources. + /// Use relative links for all resources. False by default. /// /// /// From c8630bc57b60c231ae9c6e13988f7b4039bf03f9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 21 Oct 2020 19:57:45 +0200 Subject: [PATCH 109/240] Refactored and isolated tests for resource creation (without relationships) --- .../Models/Passport.cs | 2 +- .../Services/WorkItemService.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 5 +- .../Resources/IResourceChangeTracker.cs | 15 +- .../Resources/ResourceChangeTracker.cs | 10 +- .../Serialization/BaseDeserializer.cs | 5 + .../Services/IUpdateService.cs | 2 +- .../Services/JsonApiResourceService.cs | 37 +- .../Acceptance/Spec/CreatingDataTests.cs | 145 ------- ...CreatingDataWithClientGeneratedIdsTests.cs | 62 --- .../CompositeKeys/CompositeKeyTests.cs | 6 +- .../Writing/Creating/CreateResourceTests.cs | 405 ++++++++++++++++++ ...reateResourceWithClientGeneratedIdTests.cs | 166 +++++++ .../Writing/RgbColor.cs | 11 + .../Writing/RgbColorsController.cs | 16 + .../Writing/UserAccount.cs | 18 + .../Writing/UserAccountsController.cs | 16 + .../Writing/WorkItem.cs | 28 ++ .../Writing/WorkItemGroup.cs | 22 + .../Writing/WorkItemGroupsController.cs | 17 + .../Writing/WorkItemsController.cs | 16 + .../Writing/WriteDbContext.cs | 34 ++ .../Writing/WriteFakers.cs | 22 + .../IServiceCollectionExtensionsTests.cs | 4 +- 24 files changed, 825 insertions(+), 241 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 483ecceeda..c66d74874e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -51,7 +51,7 @@ public int? SocialSecurityNumber [NotMapped] public string BirthCountryName { - get => BirthCountry.Name; + get => BirthCountry?.Name; set { BirthCountry ??= new Country(); diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 4bbe4fec08..8e87d51763 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -62,7 +62,7 @@ await QueryAsync(async connection => await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new { id })); } - public Task UpdateAsync(int id, WorkItem resourceFromRequest) + public Task UpdateAsync(int id, WorkItem resource) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index f8e1cb3d7a..38f5af7a5b 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -174,8 +174,9 @@ public virtual async Task PostAsync([FromBody] TResource resource resource = await _create.CreateAsync(resource); - // TODO: When options.AllowClientGeneratedIds, should run change tracking similar to Patch, and return 201 or 204 (see json:api spec). - return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); + return resource == null + ? (IActionResult) NoContent() + : Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); } /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index 82aae71710..cceb0994d1 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -1,31 +1,32 @@ namespace JsonApiDotNetCore.Resources { /// - /// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied. + /// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied. /// public interface IResourceChangeTracker where TResource : class, IIdentifiable { /// - /// Sets the exposed resource attributes as stored in database, before applying changes. + /// Sets the exposed resource attributes as stored in database, before applying the PATCH operation. + /// For POST operations, this sets exposed resource attributes to their default value. /// void SetInitiallyStoredAttributeValues(TResource resource); /// - /// Sets the subset of exposed attributes from the PATCH request. + /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. /// void SetRequestedAttributeValues(TResource resource); /// - /// Sets the exposed resource attributes as stored in database, after applying changes. + /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. /// void SetFinallyStoredAttributeValues(TResource resource); /// - /// Validates if any exposed resource attributes that were not in the PATCH request have been changed. - /// And validates if the values from the PATCH request are stored without modification. + /// Validates if any exposed resource attributes that were not in the POST or PATCH request have been changed. + /// And validates if the values from the request are stored without modification. /// /// - /// true if the attribute values from the PATCH request were the only changes; false, otherwise. + /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. /// bool HasImplicitChanges(); } diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 9df7cd9dd4..701b677c48 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -10,18 +10,18 @@ namespace JsonApiDotNetCore.Resources public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { private readonly IJsonApiOptions _options; - private readonly IResourceContextProvider _contextProvider; + private readonly IResourceContextProvider _resourceContextProvider; private readonly ITargetedFields _targetedFields; private IDictionary _initiallyStoredAttributeValues; private IDictionary _requestedAttributeValues; private IDictionary _finallyStoredAttributeValues; - public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider, + public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider resourceContextProvider, ITargetedFields targetedFields) { _options = options ?? throw new ArgumentNullException(nameof(options)); - _contextProvider = contextProvider ?? throw new ArgumentNullException(nameof(contextProvider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); } @@ -30,7 +30,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - var resourceContext = _contextProvider.GetResourceContext(); + var resourceContext = _resourceContextProvider.GetResourceContext(); _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } @@ -47,7 +47,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - var resourceContext = _contextProvider.GetResourceContext(); + var resourceContext = _resourceContextProvider.GetResourceContext(); _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 3c48e49a70..b353ae386c 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -137,6 +137,11 @@ private JToken LoadJToken(string body) /// The parsed resource. private IIdentifiable ParseResourceObject(ResourceObject data) { + if (data.Type == null) + { + throw new InvalidRequestBodyException("Payload must include 'type' element.", null, null); + } + var resourceContext = ResourceContextProvider.GetResourceContext(data.Type); if (resourceContext == null) { diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index a1499e3613..32003f565f 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -15,6 +15,6 @@ public interface IUpdateService /// /// Handles a json:api request to update an existing resource with attributes, relationships or both. May contain a partial set of attributes. /// - Task UpdateAsync(TId id, TResource resourceFromRequest); + Task UpdateAsync(TId id, TResource resource); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1a911cb36f..76e0fbfdb2 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -195,30 +195,41 @@ public virtual async Task CreateAsync(TResource resource) _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); + var resourceFromRequest = resource; + _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + + var defaultResource = _resourceFactory.CreateInstance(); + defaultResource.Id = resource.Id; + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); + if (_hookExecutor != null) { - resource = _hookExecutor.BeforeCreate(ToList(resource), ResourcePipeline.Post).Single(); + resourceFromRequest = _hookExecutor.BeforeCreate(ToList(resourceFromRequest), ResourcePipeline.Post).Single(); } - + try { - await _repository.CreateAsync(resource); + await _repository.CreateAsync(resourceFromRequest); } catch (DataStoreUpdateException) { - await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resource); + await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); throw; } - resource = await GetPrimaryResourceById(resource.Id, TopFieldSelection.PreserveExisting); + var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.PreserveExisting); if (_hookExecutor != null) { - _hookExecutor.AfterCreate(ToList(resource), ResourcePipeline.Post); - resource = _hookExecutor.OnReturn(ToList(resource), ResourcePipeline.Post).Single(); + _hookExecutor.AfterCreate(ToList(resourceFromDatabase), ResourcePipeline.Post); + resourceFromDatabase = _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Post).Single(); } - return resource; + _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); + + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? resourceFromDatabase : null; } /// @@ -249,16 +260,18 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, } /// - public virtual async Task UpdateAsync(TId id, TResource resourceFromRequest) + public virtual async Task UpdateAsync(TId id, TResource resource) { - _traceWriter.LogMethodStart(new {id, resourceFromRequest}); - if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); + _traceWriter.LogMethodStart(new {id, resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + var resourceFromRequest = resource; + _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); if (_hookExecutor != null) { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index e4e259206f..bc459ba71d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -79,62 +79,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task CreateResource_GuidResource_IsCreated() - { - // Arrange - var requestBody = new - { - data = new - { - type = "todoCollections", - attributes = new - { - name = "Jack" - } - } - }; - - var route = "/api/v1/todoCollections"; - - // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } - - [Fact] - public async Task ClientGeneratedId_IntegerIdAndNotEnabled_IsForbidden() - { - // Arrange - var requestBody = new - { - data = new - { - type = "todoItems", - id = "9999", - attributes = new - { - description = "some", - } - } - }; - - var route = "/api/v1/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); - responseDocument.Errors[0].Detail.Should().BeNull(); - } - [Fact] public async Task CreateWithRelationship_HasMany_IsCreated() { @@ -532,95 +476,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task CreateResource_SimpleResource_HeaderLocationsAreCorrect() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - var requestBody = new - { - data = new - { - type = "todoItems", - attributes = new - { - createdDate = todoItem.CreatedDate, - description = todoItem.Description, - ordinal = todoItem.Ordinal - } - } - }; - - var route = "/api/v1/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - var newTodoItemId = responseDocument.SingleData.Id; - httpResponse.Headers.Location.Should().Be("/api/v1/todoItems/" + newTodoItemId); - } - - [Fact] - public async Task CreateResource_UnknownResourceType_Fails() - { - // Arrange - var requestBody = new - { - data = new - { - type = "something" - } - }; - - var route = "/api/v1/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("The resource 'something' is not registered on the resource graph."); - responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); - } - - [Fact] - public async Task CreateResource_Blocked_Fails() - { - // Arrange - var requestBody = new - { - data = new - { - type = "todoItems", - attributes = new Dictionary - { - ["alwaysChangingValue"] = "X" - } - } - }; - - var route = "/api/v1/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Assigning to the requested attribute is not allowed."); - responseDocument.Errors[0].Detail.Should().StartWith("Assigning to 'alwaysChangingValue' is not allowed. - Request body:"); - } - [Fact] public async Task CreateRelationship_OneToOneWithImplicitRemove_IsCreated() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs deleted file mode 100644 index 67ed04bdca..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class CreatingDataWithClientGeneratedIdsTests : FunctionalTestCollection - { - private readonly Faker _todoItemFaker; - - public CreatingDataWithClientGeneratedIdsTests(ClientGeneratedIdsApplicationFactory factory) : base(factory) - { - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - } - - [Fact] - public async Task ClientGeneratedId_IntegerIdAndEnabled_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { e.Description, e.Ordinal, e.CreatedDate }); - var todoItem = _todoItemFaker.Generate(); - const int clientDefinedId = 9999; - todoItem.Id = clientDefinedId; - - // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(clientDefinedId, responseItem.Id); - } - - [Fact] - public async Task ClientGeneratedId_GuidIdAndEnabled_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.Owner }); - var owner = new Person(); - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - var clientDefinedId = Guid.NewGuid(); - var todoItemCollection = new TodoItemCollection { Owner = owner, OwnerId = owner.Id, Id = clientDefinedId }; - - // Act - var (body, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoItemCollection)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(clientDefinedId, responseItem.Id); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index a5c67915fc..a45290275a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -169,12 +169,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/cars"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.SingleData.Should().NotBeNull(); + responseDocument.Should().BeEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs new file mode 100644 index 0000000000..458fd2e726 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs @@ -0,0 +1,405 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Writing.Creating +{ + public sealed class CreateResourceTests : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + + public CreateResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + options.AllowClientGeneratedIds = false; + } + + [Fact] + public async Task Sets_location_header_for_created_resource() + { + // Arrange + var workItem = WriteFakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = workItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newWorkItemId = responseDocument.SingleData.Id; + httpResponse.Headers.Location.Should().Be("/workItems/" + newWorkItemId); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be("http://localhost" + httpResponse.Headers.Location); + } + + [Fact] + public async Task Can_create_resource_with_int_ID() + { + // Arrange + var workItem = WriteFakers.WorkItem.Generate(); + workItem.DueAt = null; + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = workItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().Be(workItem.DueAt); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Description.Should().Be(workItem.Description); + newWorkItemInDatabase.DueAt.Should().Be(workItem.DueAt); + }); + + var property = typeof(WorkItem).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(int)); + } + + [Fact] + public async Task Can_create_resource_with_long_ID() + { + // Arrange + var userAccount = WriteFakers.UserAccount.Generate(); + + var requestBody = new + { + data = new + { + type = "userAccounts", + attributes = new + { + firstName = userAccount.FirstName, + lastName = userAccount.LastName + } + } + }; + + var route = "/userAccounts"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("userAccounts"); + responseDocument.SingleData.Attributes["firstName"].Should().Be(userAccount.FirstName); + responseDocument.SingleData.Attributes["lastName"].Should().Be(userAccount.LastName); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newUserAccountId = responseDocument.SingleData.Id; + newUserAccountId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + + var newUserAccountInDatabase = userAccountsInDatabase.Single(p => p.StringId == newUserAccountId); + newUserAccountInDatabase.FirstName.Should().Be(userAccount.FirstName); + newUserAccountInDatabase.LastName.Should().Be(userAccount.LastName); + }); + + var property = typeof(UserAccount).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(long)); + } + + [Fact] + public async Task Can_create_resource_with_guid_ID() + { + // Arrange + var group = WriteFakers.WorkItemGroup.Generate(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + attributes = new + { + name = group.Name + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newGroupId = responseDocument.SingleData.Id; + newGroupId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups.ToListAsync(); + + var newGroupInDatabase = groupsInDatabase.Single(p => p.StringId == newGroupId); + newGroupInDatabase.Name.Should().Be(group.Name); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_create_resource_without_attributes_or_relationships() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + }, + relationship = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().BeNull(); + responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Description.Should().BeNull(); + newWorkItemInDatabase.DueAt.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_create_resource_with_client_generated_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "rgbColors", + id = "#000000", + attributes = new + { + name = "Black" + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/id"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_type() + { + // Arrange + var requestBody = new + { + data = new + { + attributes = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "unknown", + attributes = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("The resource 'unknown' is not registered on the resource graph."); + responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_with_blocked_attribute() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Assigning to the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Assigning to 'concurrencyToken' is not allowed. - Request body:"); + } + + [Fact] + public async Task Cannot_create_resource_with_readonly_attribute() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItemGroups", + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Property 'WorkItemGroup.ConcurrencyToken' is read-only. - Request body:"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs new file mode 100644 index 0000000000..46ab394fde --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Writing.Creating +{ + public sealed class CreateResourceWithClientGeneratedIdTests : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + + public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + { + // Arrange + var group = WriteFakers.WorkItemGroup.Generate(); + group.Id = Guid.NewGuid(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = group.Id, + attributes = new + { + name = group.Name + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(group.Id.ToString()); + responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups.ToListAsync(); + + var newGroupInDatabase = groupsInDatabase.Single(p => p.Id == group.Id); + newGroupInDatabase.Name.Should().Be(group.Name); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + { + // Arrange + var color = new RgbColor + { + Id = "#FF0000", + DisplayName = "Red" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = color.Id, + attributes = new + { + displayName = color.DisplayName + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors.ToListAsync(); + + var newColorInDatabase = colorsInDatabase.Single(p => p.Id == color.Id); + newColorInDatabase.DisplayName.Should().Be(color.DisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var existingColor = WriteFakers.RgbColor.Generate(); + existingColor.Id = "#FFFFFF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.RgbColors.Add(existingColor); + + await dbContext.SaveChangesAsync(); + }); + + var color = WriteFakers.RgbColor.Generate(); + color.Id = existingColor.Id; + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = color.Id, + attributes = new + { + displayName = color.DisplayName + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + // TODO: Produce a better error (409:Conflict) and assert on its details here. + responseDocument.Errors.Should().HaveCount(1); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs new file mode 100644 index 0000000000..45c1db6e01 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class RgbColor : Identifiable + { + [Attr] + public string DisplayName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs b/test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs new file mode 100644 index 0000000000..1e62c168dc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class RgbColorsController : JsonApiController + { + public RgbColorsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs new file mode 100644 index 0000000000..7b4ac2d413 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class UserAccount : Identifiable + { + [Attr] + public string FirstName { get; set; } + + [Attr] + public string LastName { get; set; } + + [HasMany] + public ISet AssignedItems { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs new file mode 100644 index 0000000000..6057d09576 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class UserAccountsController : JsonApiController + { + public UserAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs new file mode 100644 index 0000000000..944a48261c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WorkItem : Identifiable + { + [Attr] + public string Description { get; set; } + + [Attr] + public DateTime? DueAt { get; set; } + + [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public Guid ConcurrencyToken { get; set; } = Guid.NewGuid(); + + [HasOne] + public UserAccount AssignedTo { get; set; } + + [HasMany] + public ISet Subscribers { get; set; } + + [HasOne] + public WorkItemGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs new file mode 100644 index 0000000000..cc394fbef3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WorkItemGroup : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasOne] + public RgbColor Color { get; set; } + + [Attr] + public Guid ConcurrencyToken { get; } = Guid.NewGuid(); + + [HasMany] + public IList Items { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs new file mode 100644 index 0000000000..d41596fcff --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WorkItemGroupsController : JsonApiController + { + public WorkItemGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs new file mode 100644 index 0000000000..f6bbf52e02 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WorkItemsController : JsonApiController + { + public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs new file mode 100644 index 0000000000..cda235ab31 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WriteDbContext : DbContext + { + public DbSet WorkItems { get; set; } + public DbSet Groups { get; set; } + public DbSet RgbColors { get; set; } + public DbSet UserAccounts { get; set; } + + public WriteDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .Ignore(workItem => workItem.ConcurrencyToken); + + builder.Entity() + .HasOne(workItem => workItem.AssignedTo) + .WithMany(userAccount => userAccount.AssignedItems); + + builder.Entity() + .HasMany(workItem => workItem.Subscribers) + .WithOne(); + + builder.Entity() + .Ignore(workItemGroup => workItemGroup.ConcurrencyToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs new file mode 100644 index 0000000000..6cdd940b7a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs @@ -0,0 +1,22 @@ +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + internal static class WriteFakers + { + public static Faker WorkItem { get; } = new Faker() + .RuleFor(p => p.Description, f => f.Lorem.Sentence()) + .RuleFor(p => p.DueAt, f => f.Date.Future()); + + public static Faker UserAccount { get; } = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); + + public static Faker WorkItemGroup { get; } = new Faker() + .RuleFor(p => p.Name, f => f.Lorem.Word()); + + public static Faker RgbColor { get; } = new Faker() + .RuleFor(p=>p.Id, f=>f.Random.Hexadecimal(6)) + .RuleFor(p => p.DisplayName, f => f.Lorem.Word()); + } +} diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 901b8871af..481f5b5026 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -165,7 +165,7 @@ private class IntResourceService : IResourceService public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(int id, IntResource resourceFromRequest) => throw new NotImplementedException(); + public Task UpdateAsync(int id, IntResource resource) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); @@ -179,7 +179,7 @@ private class GuidResourceService : IResourceService public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(Guid id, GuidResource resourceFromRequest) => throw new NotImplementedException(); + public Task UpdateAsync(Guid id, GuidResource resource) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); From 411b52dc045966172ebb345a37598817b2c98ccf Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 22 Oct 2020 00:26:52 +0200 Subject: [PATCH 110/240] Added tests for create with relationships --- .../Controllers/TodoItemsCustomController.cs | 2 - .../Repositories/DbContextExtensions.cs | 2 - .../Repositories/IResourceWriteRepository.cs | 1 - .../Serialization/BaseDeserializer.cs | 62 +- .../Building/IncludedResourceObjectBuilder.cs | 6 +- .../Acceptance/Spec/CreatingDataTests.cs | 540 --------- .../Spec/UpdatingRelationshipsTests.cs | 4 +- .../CompositeKeys/CompositeKeyTests.cs | 11 +- .../ModelStateValidationTests.cs | 95 +- .../NoModelStateValidationTests.cs | 11 +- .../InheritanceDbContext.cs | 6 +- .../ResourceInheritance/InheritanceTests.cs | 281 +++-- .../ResourceInheritance/Models/Human.cs | 5 +- .../SoftDeletion/SoftDeletionTests.cs | 4 +- .../Writing/Creating/CreateResourceTests.cs | 49 +- ...reateResourceWithClientGeneratedIdTests.cs | 8 +- .../CreateResourceWithRelationshipTests.cs | 1019 +++++++++++++++++ .../Writing/RgbColor.cs | 3 + .../Writing/WorkItem.cs | 10 + .../Writing/WorkItemGroup.cs | 8 +- .../Writing/WorkItemPriority.cs | 9 + .../Writing/WorkItemTag.cs | 11 + .../Writing/WorkTag.cs | 14 + .../Writing/WriteDbContext.cs | 12 +- .../Writing/WriteFakers.cs | 7 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 1 - 26 files changed, 1448 insertions(+), 733 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index c944145c05..ab63c950d9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index ca8b0d5524..d2a980791e 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,9 +1,7 @@ using System; using System.Linq; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; namespace JsonApiDotNetCore.Repositories { diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index f1057d2790..d5e4657380 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Repositories { diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index b353ae386c..665ec2547f 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -137,16 +137,13 @@ private JToken LoadJToken(string body) /// The parsed resource. private IIdentifiable ParseResourceObject(ResourceObject data) { - if (data.Type == null) - { - throw new InvalidRequestBodyException("Payload must include 'type' element.", null, null); - } + AssertHasType(data, null); var resourceContext = ResourceContextProvider.GetResourceContext(data.Type); if (resourceContext == null) { throw new InvalidRequestBodyException("Payload includes unknown resource type.", - $"The resource '{data.Type}' is not registered on the resource graph. " + + $"The resource type '{data.Type}' is not registered on the resource graph. " + "If you are using Entity Framework Core, make sure the DbSet matches the expected resource name. " + "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); } @@ -168,29 +165,35 @@ private IIdentifiable ParseResourceObject(ResourceObject data) /// private void SetHasOneRelationship(IIdentifiable resource, PropertyInfo[] resourceProperties, - HasOneAttribute attr, + HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) { var rio = (ResourceIdentifierObject)relationshipData.Data; var relatedId = rio?.Id; + if (relationshipData.SingleData != null) + { + AssertHasType(relationshipData.SingleData, hasOneRelationship); + AssertHasId(relationshipData.SingleData, hasOneRelationship); + } + var relationshipType = relationshipData.SingleData == null - ? attr.RightType + ? hasOneRelationship.RightType : ResourceContextProvider.GetResourceContext(relationshipData.SingleData.Type).ResourceType; - // this does not make sense in the following case: if we're setting the dependent of a one-to-one relationship, IdentifiablePropertyName should be null. - var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == attr.IdentifiablePropertyName); + // TODO: this does not make sense in the following case: if we're setting the dependent of a one-to-one relationship, IdentifiablePropertyName should be null. + var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == hasOneRelationship.IdentifiablePropertyName); if (foreignKeyProperty != null) // there is a FK from the current resource pointing to the related object, // i.e. we're populating the relationship from the dependent side. - SetForeignKey(resource, foreignKeyProperty, attr, relatedId, relationshipType); + SetForeignKey(resource, foreignKeyProperty, hasOneRelationship, relatedId, relationshipType); - SetNavigation(resource, attr, relatedId, relationshipType); + SetNavigation(resource, hasOneRelationship, relatedId, relationshipType); // depending on if this base parser is used client-side or server-side, // different additional processing per field needs to be executed. - AfterProcessField(resource, attr, relationshipData); + AfterProcessField(resource, hasOneRelationship, relationshipData); } /// @@ -237,13 +240,17 @@ private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string /// private void SetHasManyRelationship( IIdentifiable resource, - HasManyAttribute attr, + HasManyAttribute hasManyRelationship, RelationshipEntry relationshipData) { if (relationshipData.Data != null) - { // if the relationship is set to null, no need to set the navigation property to null: this is the default value. + { + // if the relationship is set to null, no need to set the navigation property to null: this is the default value. var relatedResources = relationshipData.ManyData.Select(rio => { + AssertHasType(rio, hasManyRelationship); + AssertHasId(rio, hasManyRelationship); + var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType; var relatedInstance = ResourceFactory.CreateInstance(relationshipType); relatedInstance.StringId = rio.Id; @@ -251,11 +258,32 @@ private void SetHasManyRelationship( return relatedInstance; }); - var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, attr.Property.PropertyType); - attr.SetValue(resource, convertedCollection, ResourceFactory); + var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, hasManyRelationship.Property.PropertyType); + hasManyRelationship.SetValue(resource, convertedCollection, ResourceFactory); } - AfterProcessField(resource, attr, relationshipData); + AfterProcessField(resource, hasManyRelationship, relationshipData); + } + + private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + { + if (resourceIdentifierObject.Type == null) + { + var details = relationship != null + ? $"Expected 'type' element in relationship '{relationship.PublicName}'." + : "Expected 'type' element in 'data' element."; + + throw new InvalidRequestBodyException("Payload must include 'type' element.", details, null); + } + } + + private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + { + if (resourceIdentifierObject.Id == null) + { + throw new InvalidRequestBodyException("Payload must include 'id' element.", + $"Expected 'id' element in relationship '{relationship.PublicName}'.", null); + } } private object ConvertAttrValue(object newValue, Type targetType) diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 0b81bf3553..38468d6f75 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -38,7 +38,8 @@ public IList Build() foreach (var resourceObject in _included) { if (resourceObject.Relationships != null) - { // removes relationship entries (s) if they're completely empty. + { + // removes relationship entries (s) if they're completely empty. var pruned = resourceObject.Relationships.Where(p => p.Value.IsPopulated || p.Value.Links != null).ToDictionary(p => p.Key, p => p.Value); if (!pruned.Any()) pruned = null; resourceObject.Relationships = pruned; @@ -104,7 +105,8 @@ private void ProcessRelationship(RelationshipAttribute originRelationship, IIden relationshipEntry.Data = GetRelatedResourceLinkage(nextRelationship, parent); if (relationshipEntry.HasResource) - { // if the relationship is set, continue parsing the chain. + { + // if the relationship is set, continue parsing the chain. var related = nextRelationship.GetValue(parent); ProcessChain(nextRelationship, related, chainRemainder); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs deleted file mode 100644 index bc459ba71d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ /dev/null @@ -1,540 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class CreatingDataTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - private readonly Faker _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - private readonly Faker _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - public CreatingDataTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = false; - } - - [Fact] - public async Task CreateResource_ModelWithEntityFrameworkInheritance_IsCreated() - { - // Arrange - var requestBody = new - { - data = new - { - type = "superUsers", - attributes = new - { - securityLevel = 1337, - userName = "Jack", - password = "secret" - } - } - }; - - var route = "/api/v1/superUsers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["securityLevel"].Should().Be(1337); - responseDocument.SingleData.Attributes["userName"].Should().Be("Jack"); - - var newSuperUserId = responseDocument.SingleData.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var superUserInDatabase = await dbContext.Set() - .Where(superUser => superUser.Id == int.Parse(newSuperUserId)) - .SingleAsync(); - - superUserInDatabase.Password.Should().Be("secret"); - }); - } - - [Fact] - public async Task CreateWithRelationship_HasMany_IsCreated() - { - // Arrange - var todoItems = _todoItemFaker.Generate(3); - - var existingPerson = _personFaker.Generate(); - existingPerson.TodoItems = todoItems.ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingPerson); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - relationships = new - { - todoItems = new - { - data = new[] - { - new - { - type = "todoItems", - id = todoItems[0].StringId - }, - new - { - type = "todoItems", - id = todoItems[1].StringId - } - } - } - } - } - }; - - var route = "/api/v1/people"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - var newPersonId = responseDocument.SingleData.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personsInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .ToListAsync(); - - var existingPersonInDatabase = personsInDatabase.Single(p => p.Id == existingPerson.Id); - existingPersonInDatabase.TodoItems.Should().HaveCount(1); - existingPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[2].Id); - - var newPersonInDatabase = personsInDatabase.Single(p => p.StringId == newPersonId); - newPersonInDatabase.TodoItems.Should().HaveCount(2); - newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[0].Id); - newPersonInDatabase.TodoItems.Should().ContainSingle(item => item.Id == todoItems[1].Id); - }); - } - - [Fact] - public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncluded() - { - // Arrange - var existingTodoItem = _todoItemFaker.Generate(); - existingTodoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(existingTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoCollections", - relationships = new - { - todoItems = new - { - data = new[] - { - new - { - type = "todoItems", - id = existingTodoItem.StringId - } - } - } - } - } - }; - - var route = "/api/v1/todoCollections?include=todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("todoItems"); - responseDocument.Included[0].Id.Should().Be(existingTodoItem.StringId); - responseDocument.Included[0].Attributes["description"].Should().Be(existingTodoItem.Description); - } - - [Fact] - public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCreatedAndIncluded() - { - // Arrange - var existingTodoItem = _todoItemFaker.Generate(); - existingTodoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(existingTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoCollections", - attributes = new - { - name = "Jack" - }, - relationships = new - { - todoItems = new - { - data = new[] - { - new - { - type = "todoItems", - id = existingTodoItem.StringId - } - } - }, - owner = new - { - data = new - { - type = "people", - id = existingTodoItem.Owner.StringId - } - } - } - } - }; - - var route = "/api/v1/todoCollections?include=todoItems&fields=name&fields[todoItems]=ordinal"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be("Jack"); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("todoItems"); - responseDocument.Included[0].Id.Should().Be(existingTodoItem.StringId); - responseDocument.Included[0].Attributes["ordinal"].Should().Be(existingTodoItem.Ordinal); - responseDocument.Included[0].Attributes.Should().NotContainKey("description"); - } - - [Fact] - public async Task CreateWithRelationship_HasOne_IsCreated() - { - // Arrange - var existingOwner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingOwner); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - relationships = new - { - owner = new - { - data = new - { - type = "people", - id = existingOwner.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - var newTodoItemId = responseDocument.SingleData.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.Owner) - .Where(item => item.Id == int.Parse(newTodoItemId)) - .SingleAsync(); - - todoItemInDatabase.Owner.Id.Should().Be(existingOwner.Id); - }); - } - - [Fact] - public async Task CreateWithRelationship_HasOneAndInclude_IsCreatedAndIncluded() - { - // Arrange - var existingOwner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingOwner); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - attributes = new - { - description = "some" - }, - relationships = new - { - owner = new - { - data = new - { - type = "people", - id = existingOwner.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems?include=owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["description"].Should().Be("some"); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("people"); - responseDocument.Included[0].Id.Should().Be(existingOwner.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingOwner.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingOwner.LastName); - } - - [Fact] - public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCreatedAndIncluded() - { - // Arrange - var existingOwner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingOwner); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - attributes = new - { - ordinal = 123, - description = "some" - }, - relationships = new - { - owner = new - { - data = new - { - type = "people", - id = existingOwner.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems?include=owner&fields=ordinal&fields[owner]=firstName"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["ordinal"].Should().Be(123); - responseDocument.SingleData.Attributes.Should().NotContainKey("description"); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("people"); - responseDocument.Included[0].Id.Should().Be(existingOwner.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingOwner.FirstName); - responseDocument.Included[0].Attributes.Should().NotContainKey("lastName"); - } - - [Fact] - public async Task CreateWithRelationship_HasOneFromIndependentSide_IsCreated() - { - // Arrange - var existingPerson = new Person(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingPerson); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "personRoles", - relationships = new - { - person = new - { - data = new - { - type = "people", - id = existingPerson.StringId - } - } - } - } - }; - - var route = "/api/v1/personRoles"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - var newPersonRoleId = responseDocument.SingleData.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personRoleInDatabase = await dbContext.PersonRoles - .Include(role => role.Person) - .Where(role => role.Id == int.Parse(newPersonRoleId)) - .SingleAsync(); - - personRoleInDatabase.Person.Id.Should().Be(existingPerson.Id); - }); - } - - [Fact] - public async Task CreateRelationship_OneToOneWithImplicitRemove_IsCreated() - { - // Arrange - var existingPerson = _personFaker.Generate(); - - Passport passport = null; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - - passport = new Passport(dbContext); - existingPerson.Passport = passport; - - dbContext.People.Add(existingPerson); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - relationships = new - { - passport = new - { - data = new - { - type = "passports", - id = passport.StringId - } - } - } - } - }; - - var route = "/api/v1/people"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - var newPersonId = responseDocument.SingleData.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personsInDatabase = await dbContext.People - .Include(p => p.Passport) - .ToListAsync(); - - var existingPersonInDatabase = personsInDatabase.Single(p => p.Id == existingPerson.Id); - existingPersonInDatabase.Passport.Should().BeNull(); - - var newPersonInDatabase = personsInDatabase.Single(p => p.StringId == newPersonId); - newPersonInDatabase.Passport.Id.Should().Be(passport.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 931d179442..5513cb4484 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -868,7 +868,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/invalid"; + var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/doesNotExist"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -879,7 +879,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'invalid'."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'doesNotExist'."); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index a45290275a..9c143bcc38 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; @@ -204,11 +203,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "engines", id = engine.StringId, - relationships = new Dictionary + relationships = new { - ["car"] = new + car = new { - data = (object)null + data = (object) null } } } @@ -262,9 +261,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "engines", id = engine.StringId, - relationships = new Dictionary + relationships = new { - ["car"] = new + car = new { data = new { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index b80ef6e811..d118ab9927 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -5,7 +5,6 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation @@ -28,9 +27,9 @@ public async Task When_posting_resource_with_omitted_required_attribute_value_it data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["isCaseSensitive"] = "true" + isCaseSensitive = true } } }; @@ -59,10 +58,10 @@ public async Task When_posting_resource_with_null_for_required_attribute_value_i data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = null, - ["isCaseSensitive"] = "true" + name = (string) null, + isCaseSensitive = true } } }; @@ -91,10 +90,10 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_fai data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-", - ["isCaseSensitive"] = "true" + name = "!@#$%^&*().-", + isCaseSensitive = true } } }; @@ -123,10 +122,10 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "Projects", - ["isCaseSensitive"] = "true" + name = "Projects", + isCaseSensitive = true } } }; @@ -153,9 +152,9 @@ public async Task When_posting_resource_with_multiple_violations_it_must_fail() data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["sizeInBytes"] = "-1" + sizeInBytes = -1 } } }; @@ -221,14 +220,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "Projects", - ["isCaseSensitive"] = "true" + name = "Projects", + isCaseSensitive = true }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -239,7 +238,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["files"] = new + files = new { data = new[] { @@ -250,7 +249,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["parent"] = new + parent = new { data = new { @@ -342,9 +341,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["sizeInBytes"] = "100" + sizeInBytes = 100 } } }; @@ -382,9 +381,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = null + name = (string) null } } }; @@ -426,9 +425,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-" + name = "!@#$%^&*().-" } } }; @@ -470,13 +469,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = -1, - attributes = new Dictionary + attributes = new { - ["name"] = "Repositories" + name = "Repositories" }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -534,9 +533,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Repositories" + name = "Repositories" } } }; @@ -613,13 +612,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Project Files" + name = "Project Files" }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -630,7 +629,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["files"] = new + files = new { data = new[] { @@ -641,7 +640,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["parent"] = new + parent = new { data = new { @@ -686,13 +685,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Project files" + name = "Project files" }, - relationships = new Dictionary + relationships = new { - ["self"] = new + self = new { data = new { @@ -700,7 +699,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = directory.StringId } }, - ["alsoSelf"] = new + alsoSelf = new { data = new { @@ -745,13 +744,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Project files" + name = "Project files" }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index ff3b22c520..31096c4423 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation @@ -26,10 +25,10 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_suc data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-", - ["isCaseSensitive"] = "false" + name = "!@#$%^&*().-", + isCaseSensitive = "false" } } }; @@ -68,9 +67,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-" + name = "!@#$%^&*().-" } } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs index 530cf8bbc2..f21eadc1af 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs @@ -8,13 +8,9 @@ public sealed class InheritanceDbContext : DbContext public InheritanceDbContext(DbContextOptions options) : base(options) { } public DbSet Humans { get; set; } - public DbSet Men { get; set; } - public DbSet CompanyHealthInsurances { get; set; } - public DbSet ContentItems { get; set; } - public DbSet HumanFavoriteContentItems { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -35,7 +31,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasValue(2); modelBuilder.Entity() - .HasKey(hfci => new { ContentPersonId = hfci.ContentItemId, PersonId = hfci.HumanId }); + .HasKey(item => new { ContentPersonId = item.ContentItemId, PersonId = item.HumanId }); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 04ece493df..77e07765eb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -20,119 +19,192 @@ public InheritanceTests(IntegrationTestContext(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("men"); + responseDocument.SingleData.Attributes["familyName"].Should().Be(man.FamilyName); + responseDocument.SingleData.Attributes["isRetired"].Should().Be(man.IsRetired); + responseDocument.SingleData.Attributes["hasBeard"].Should().Be(man.HasBeard); + + var newManId = responseDocument.SingleData.Id; + newManId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var manInDatabase = await dbContext.Men + .SingleAsync(m => m.Id == int.Parse(newManId)); + + manInDatabase.FamilyName.Should().Be(man.FamilyName); + manInDatabase.IsRetired.Should().Be(man.IsRetired); + manInDatabase.HasBeard.Should().Be(man.HasBeard); + }); + } + + [Fact] + public async Task Can_create_resource_with_ToOne_relationship() + { + // Arrange + var existingInsurance = new CompanyHealthInsurance(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.CompanyHealthInsurances.Add(insurance); + dbContext.CompanyHealthInsurances.Add(existingInsurance); + await dbContext.SaveChangesAsync(); }); - var route = "/men"; var requestBody = new { data = new { type = "men", - relationships = new Dictionary + relationships = new { + healthInsurance = new { - "healthInsurance", new + data = new { - data = new { type = "companyHealthInsurances", id = insurance.StringId } + type = "companyHealthInsurances", + id = existingInsurance.StringId } } } } }; + var route = "/men"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.SingleData.Should().NotBeNull(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men - .Include(m => m.HealthInsurance) - .SingleAsync(m => m.Id == int.Parse(responseDocument.SingleData.Id)); + .Include(man => man.HealthInsurance) + .SingleAsync(man => man.Id == int.Parse(responseDocument.SingleData.Id)); manInDatabase.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); }); } [Fact] - public async Task Can_patch_resource_with_to_one_relationship_through_relationship_link() + public async Task Can_update_resource_with_ToOne_relationship_through_relationship_endpoint() { // Arrange - var man = new Man(); - var insurance = new CompanyHealthInsurance(); + var existingMan = new Man(); + var existingInsurance = new CompanyHealthInsurance(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.AddRange(man, insurance); + dbContext.AddRange(existingMan, existingInsurance); + await dbContext.SaveChangesAsync(); }); - - var route = $"/men/{man.Id}/relationships/healthInsurance"; var requestBody = new { - data = new { type = "companyHealthInsurances", id = insurance.StringId } + data = new + { + type = "companyHealthInsurances", + id = existingInsurance.StringId + } }; + var route = $"/men/{existingMan.StringId}/relationships/healthInsurance"; + // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.Should().BeNull(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men - .Include(m => m.HealthInsurance) - .SingleAsync(h => h.Id == man.Id); + .Include(man => man.HealthInsurance) + .SingleAsync(man => man.Id == existingMan.Id); manInDatabase.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); }); } - [Fact] - public async Task Can_create_resource_with_to_many_relationship() + public async Task Can_create_resource_with_ToMany_relationship() { // Arrange - var father = new Man(); - var mother = new Woman(); + var existingFather = new Man(); + var existingMother = new Woman(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.Humans.AddRange(father, mother); + dbContext.Humans.AddRange(existingFather, existingMother); + await dbContext.SaveChangesAsync(); }); - var route = "/men"; var requestBody = new { data = new { type = "men", - relationships = new Dictionary + relationships = new { + parents = new { - "parents", new + data = new[] { - data = new[] + new { - new { type = "men", id = father.StringId }, - new { type = "women", id = mother.StringId } + type = "men", + id = existingFather.StringId + }, + new + { + type = "women", + id = existingMother.StringId } } } @@ -140,163 +212,204 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; + var route = "/men"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.SingleData.Should().NotBeNull(); + + var newManId = responseDocument.SingleData.Id; + newManId.Should().NotBeNullOrEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men - .Include(m => m.Parents) - .SingleAsync(m => m.Id == int.Parse(responseDocument.SingleData.Id)); + .Include(man => man.Parents) + .SingleAsync(man => man.Id == int.Parse(newManId)); manInDatabase.Parents.Should().HaveCount(2); - manInDatabase.Parents.Should().ContainSingle(h => h is Man); - manInDatabase.Parents.Should().ContainSingle(h => h is Woman); + manInDatabase.Parents.Should().ContainSingle(human => human is Man); + manInDatabase.Parents.Should().ContainSingle(human => human is Woman); }); } [Fact] - public async Task Can_patch_resource_with_to_many_relationship_through_relationship_link() + public async Task Can_update_resource_with_ToMany_relationship_through_relationship_endpoint() { // Arrange - var child = new Man(); - var father = new Man(); - var mother = new Woman(); + var existingChild = new Man(); + var existingFather = new Man(); + var existingMother = new Woman(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.Humans.AddRange(child, father, mother); + dbContext.Humans.AddRange(existingChild, existingFather, existingMother); + await dbContext.SaveChangesAsync(); }); - - var route = $"/men/{child.StringId}/relationships/parents"; + var requestBody = new { data = new[] { - new { type = "men", id = father.StringId }, - new { type = "women", id = mother.StringId } + new + { + type = "men", + id = existingFather.StringId + }, + new + { + type = "women", + id = existingMother.StringId + } } }; - + + var route = $"/men/{existingChild.StringId}/relationships/parents"; + // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - + + responseDocument.Data.Should().BeNull(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men - .Include(m => m.Parents) - .SingleAsync(m => m.Id == child.Id); + .Include(man => man.Parents) + .SingleAsync(man => man.Id == existingChild.Id); manInDatabase.Parents.Should().HaveCount(2); - manInDatabase.Parents.Should().ContainSingle(h => h is Man); - manInDatabase.Parents.Should().ContainSingle(h => h is Woman); + manInDatabase.Parents.Should().ContainSingle(human => human is Man); + manInDatabase.Parents.Should().ContainSingle(human => human is Woman); }); } [Fact] - public async Task Can_create_resource_with_many_to_many_relationship() + public async Task Can_create_resource_with_ManyToMany_relationship() { // Arrange - var book = new Book(); - var video = new Video(); + var existingBook = new Book(); + var existingVideo = new Video(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.ContentItems.AddRange(book, video); + dbContext.ContentItems.AddRange(existingBook, existingVideo); + await dbContext.SaveChangesAsync(); }); - - var route = "/men"; + var requestBody = new { data = new { type = "men", - relationships = new Dictionary + relationships = new { + favoriteContent = new { - "favoriteContent", new + data = new[] { - data = new[] + new + { + type = "books", + id = existingBook.StringId + }, + new { - new { type = "books", id = book.StringId }, - new { type = "videos", id = video.StringId } + type = "videos", + id = existingVideo.StringId } } } } } }; - + + var route = "/men"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.SingleData.Should().NotBeNull(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems - .Where(hfci => hfci.Human.Id == int.Parse(responseDocument.SingleData.Id)) - .Select(hfci => hfci.ContentItem) + .Where(favorite => favorite.Human.Id == int.Parse(responseDocument.SingleData.Id)) + .Select(favorite => favorite.ContentItem) .ToListAsync(); - + contentItems.Should().HaveCount(2); - contentItems.Should().ContainSingle(ci => ci is Book); - contentItems.Should().ContainSingle(ci => ci is Video); + contentItems.Should().ContainSingle(item => item is Book); + contentItems.Should().ContainSingle(item => item is Video); }); } [Fact] - public async Task Can_patch_resource_with_many_to_many_relationship_through_relationship_link() + public async Task Can_update_resource_with_ManyToMany_relationship_through_relationship_endpoint() { // Arrange - var book = new Book(); - var video = new Video(); - var man = new Man(); + var existingBook = new Book(); + var existingVideo = new Video(); + var existingMan = new Man(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.AddRange(book, video, man); + dbContext.AddRange(existingBook, existingVideo, existingMan); + await dbContext.SaveChangesAsync(); }); - - var route = $"/men/{man.Id}/relationships/favoriteContent"; + var requestBody = new { data = new[] { - new { type = "books", id = book.StringId }, - new { type = "videos", id = video.StringId } + new + { + type = "books", + id = existingBook.StringId + }, + new + { + type = "videos", + id = existingVideo.StringId + } } }; - + + var route = $"/men/{existingMan.StringId}/relationships/favoriteContent"; + // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + responseDocument.Data.Should().BeNull(); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems - .Where(hfci => hfci.Human.Id == man.Id) - .Select(hfci => hfci.ContentItem) + .Where(favorite => favorite.Human.Id == existingMan.Id) + .Select(favorite => favorite.ContentItem) .ToListAsync(); contentItems.Should().HaveCount(2); - contentItems.Should().ContainSingle(ci => ci is Book); - contentItems.Should().ContainSingle(ci => ci is Video); + contentItems.Should().ContainSingle(item => item is Book); + contentItems.Should().ContainSingle(item => item is Video); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs index fdbb27bfe2..e4177e1c91 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs @@ -8,7 +8,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance.Mod public abstract class Human : Identifiable { [Attr] - public bool Retired { get; set; } + public string FamilyName { get; set; } + + [Attr] + public bool IsRetired { get; set; } [HasOne] public HealthInsurance HealthInsurance { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index e69a5ad5e9..20d6e77520 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -372,9 +372,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "companies", id = company.StringId, - attributes = new Dictionary + attributes = new { - {"name", "Umbrella Corporation"} + name = "Umbrella Corporation" } } }; diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs index 458fd2e726..da404b6cd7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs @@ -252,6 +252,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_create_resource_with_unknown_attribute() + { + // Arrange + var workItem = WriteFakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + doesNotExist = "ignored", + description = workItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Description.Should().Be(workItem.Description); + }); + } + [Fact] public async Task Cannot_create_resource_with_client_generated_ID() { @@ -309,7 +352,7 @@ public async Task Cannot_create_resource_for_missing_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } [Fact] @@ -320,7 +363,7 @@ public async Task Cannot_create_resource_for_unknown_type() { data = new { - type = "unknown", + type = "doesNotExist", attributes = new { } @@ -338,7 +381,7 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("The resource 'unknown' is not registered on the resource graph."); + responseDocument.Errors[0].Detail.Should().StartWith("The resource type 'doesNotExist' is not registered on the resource graph."); responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); } diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 46ab394fde..b4cba265bb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -37,7 +37,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ data = new { type = "workItemGroups", - id = group.Id, + id = group.StringId, attributes = new { name = group.Name @@ -55,7 +55,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); - responseDocument.SingleData.Id.Should().Be(group.Id.ToString()); + responseDocument.SingleData.Id.Should().Be(group.StringId); responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -90,7 +90,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "rgbColors", - id = color.Id, + id = color.StringId, attributes = new { displayName = color.DisplayName @@ -143,7 +143,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "rgbColors", - id = color.Id, + id = color.StringId, attributes = new { displayName = color.DisplayName diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs new file mode 100644 index 0000000000..4c8177caf9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -0,0 +1,1019 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Writing.Creating +{ + public sealed class CreateResourceWithRelationshipTests : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + + public CreateResourceWithRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_resource_with_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = WriteFakers.WorkItemGroup.Generate(); + existingGroup.Color = WriteFakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingGroup.Color.StringId + } + } + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newGroupId = responseDocument.SingleData.Id; + newGroupId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var newGroupInDatabase = groupsInDatabase.Single(p => p.StringId == newGroupId); + newGroupInDatabase.Color.Should().NotBeNull(); + newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); + + var existingGroupInDatabase = groupsInDatabase.Single(p => p.Id == existingGroup.Id); + existingGroupInDatabase.Color.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_resource_with_OneToOne_relationship_from_dependent_side_with_implicit_remove() + { + // Arrange + var existingColor = WriteFakers.RgbColor.Generate(); + existingColor.Group = WriteFakers.WorkItemGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + string colorId = "#112233"; + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = colorId, + relationships = new + { + group = new + { + data = new + { + type = "workItemGroups", + id = existingColor.Group.StringId + } + } + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var newColorInDatabase = colorsInDatabase.Single(p => p.Id == colorId); + newColorInDatabase.Group.Should().NotBeNull(); + newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); + + var existingColorInDatabase = colorsInDatabase.Single(p => p.Id == existingColor.Id); + existingColorInDatabase.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_resource_with_HasOne_relationship_with_include() + { + // Arrange + var existingUserAccount = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems?include=assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); + newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_create_resource_with_HasOne_relationship_with_include_and_primary_fieldset() + { + // Arrange + var existingUserAccount = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var workItem = WriteFakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = workItem.Description, + priority = workItem.Priority + }, + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems?fields=description&include=assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes.Should().NotContainKey("priority"); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Description.Should().Be(workItem.Description); + newWorkItemInDatabase.Priority.Should().Be(workItem.Priority); + newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); + newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignedTo = new + { + data = new + { + id = "12345678" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in relationship 'assignedTo'. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in relationship 'assignedTo'. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = "12345678" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignedTo' does not exist."); + } + + [Fact] + public async Task Can_create_resource_with_HasMany_relationship() + { + // Arrange + var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Included.Should().BeNull(); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Subscribers.Should().HaveCount(2); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(x => x.Id == existingUserAccounts[0].Id); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(x => x.Id == existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_resource_with_HasMany_relationship_with_include() + { + // Arrange + var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems?include=subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().OnlyContain(p => p.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(p => p.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(p => p.Attributes["lastName"] != null); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Subscribers.Should().HaveCount(2); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[0].Id); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_resource_with_HasMany_relationship_with_include_and_secondary_fieldset() + { + // Arrange + var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems?include=subscribers&fields[subscribers]=firstName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().OnlyContain(p => p.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(p => p.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(p => !p.Attributes.ContainsKey("lastName")); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Subscribers.Should().HaveCount(2); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[0].Id); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_resource_with_duplicate_HasMany_relationships() + { + // Arrange + var existingUserAccount = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + }, + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + } + }; + + var route = "/workItems?include=subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Subscribers.Should().HaveCount(1); + newWorkItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_create_resource_with_HasManyThrough_relationship_with_include_and_fieldsets() + { + // Arrange + var existingTags = WriteFakers.WorkTags.Generate(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var workItem = WriteFakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = workItem.Description, + priority = workItem.Priority + }, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + }, + new + { + type = "workTags", + id = existingTags[2].StringId + } + } + } + } + } + }; + + var route = "/workItems?fields=priority&include=tags&fields[tags]=text"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); + responseDocument.SingleData.Attributes.Should().NotContainKey("description"); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.Should().OnlyContain(p => p.Type == "workTags"); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[0].StringId); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[1].StringId); + responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[2].StringId); + responseDocument.Included.Should().OnlyContain(p => p.Attributes["text"] != null); + responseDocument.Included.Should().OnlyContain(p => !p.Attributes.ContainsKey("isBuiltIn")); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.WorkItemTags.Should().HaveCount(3); + newWorkItemInDatabase.WorkItemTags.Should().ContainSingle(p => p.Tag.Id == existingTags[0].Id); + newWorkItemInDatabase.WorkItemTags.Should().ContainSingle(p => p.Tag.Id == existingTags[1].Id); + newWorkItemInDatabase.WorkItemTags.Should().ContainSingle(p => p.Tag.Id == existingTags[2].Id); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExists", + id = "12345678" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.Description.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + id = "12345678" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in relationship 'subscribers'. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in relationship 'subscribers'. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_HasMany_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "userAccounts", + relationships = new + { + assignedItems = new + { + data = new[] + { + new + { + type = "workItems", + id = "12345678" + } + } + } + } + } + }; + + var route = "/userAccounts"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'workItems' with ID '12345678' being assigned to relationship 'assignedItems' does not exist."); + } + + [Fact] + public async Task Can_create_resource_with_multiple_relationship_types() + { + // Arrange + var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + var existingTag = WriteFakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + dbContext.WorkTags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + }, + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + newWorkItemId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .ToListAsync(); + + var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); + newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); + newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccounts[0].Id); + newWorkItemInDatabase.Subscribers.Should().HaveCount(1); + newWorkItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingUserAccounts[1].Id); + newWorkItemInDatabase.WorkItemTags.Should().HaveCount(1); + newWorkItemInDatabase.WorkItemTags.ElementAt(0).Tag.Id.Should().Be(existingTag.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs index 45c1db6e01..841b8ee0b1 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs @@ -7,5 +7,8 @@ public sealed class RgbColor : Identifiable { [Attr] public string DisplayName { get; set; } + + [HasOne] + public WorkItemGroup Group { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs index 944a48261c..e3131727de 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,6 +14,10 @@ public sealed class WorkItem : Identifiable [Attr] public DateTime? DueAt { get; set; } + [Attr] + public WorkItemPriority Priority { get; set; } + + [NotMapped] [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public Guid ConcurrencyToken { get; set; } = Guid.NewGuid(); @@ -22,6 +27,11 @@ public sealed class WorkItem : Identifiable [HasMany] public ISet Subscribers { get; set; } + [NotMapped] + [HasManyThrough(nameof(WorkItemTags))] + public ISet Tags { get; set; } + public ICollection WorkItemTags { get; set; } + [HasOne] public WorkItemGroup Group { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs index cc394fbef3..77adfefb94 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -10,12 +11,13 @@ public sealed class WorkItemGroup : Identifiable [Attr] public string Name { get; set; } - [HasOne] - public RgbColor Color { get; set; } - + [NotMapped] [Attr] public Guid ConcurrencyToken { get; } = Guid.NewGuid(); + [HasOne] + public RgbColor Color { get; set; } + [HasMany] public IList Items { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs new file mode 100644 index 0000000000..55bead40ca --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public enum WorkItemPriority + { + Low, + Medium, + High + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs new file mode 100644 index 0000000000..553ec7b423 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WorkItemTag + { + public WorkItem Item { get; set; } + public int ItemId { get; set; } + + public WorkTag Tag { get; set; } + public int TagId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs new file mode 100644 index 0000000000..b787d5e986 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.Writing +{ + public sealed class WorkTag : Identifiable + { + [Attr] + public string Text { get; set; } + + [Attr] + public bool IsBuiltIn { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs index cda235ab31..0ff224d8d5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreExampleTests.Writing public sealed class WriteDbContext : DbContext { public DbSet WorkItems { get; set; } + public DbSet WorkTags { get; set; } public DbSet Groups { get; set; } public DbSet RgbColors { get; set; } public DbSet UserAccounts { get; set; } @@ -16,9 +17,6 @@ public WriteDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { - builder.Entity() - .Ignore(workItem => workItem.ConcurrencyToken); - builder.Entity() .HasOne(workItem => workItem.AssignedTo) .WithMany(userAccount => userAccount.AssignedItems); @@ -29,6 +27,14 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .Ignore(workItemGroup => workItemGroup.ConcurrencyToken); + + builder.Entity() + .HasOne(workItemGroup => workItemGroup.Color) + .WithOne(x => x.Group) + .HasForeignKey(); + + builder.Entity() + .HasKey(workItemTag => new {workItemTag.ItemId, workItemTag.TagId}); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs index 6cdd940b7a..0a4d9e5f7d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs @@ -6,7 +6,12 @@ internal static class WriteFakers { public static Faker WorkItem { get; } = new Faker() .RuleFor(p => p.Description, f => f.Lorem.Sentence()) - .RuleFor(p => p.DueAt, f => f.Date.Future()); + .RuleFor(p => p.DueAt, f => f.Date.Future()) + .RuleFor(p => p.Priority, f => f.PickRandom()); + + public static Faker WorkTags { get; } = new Faker() + .RuleFor(p => p.Text, f => f.Lorem.Word()) + .RuleFor(p => p.IsBuiltIn, f => f.Random.Bool()); public static Faker UserAccount { get; } = new Faker() .RuleFor(p => p.FirstName, f => f.Name.FirstName()) diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 8383913706..9be672f800 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using Bogus; -using Castle.Core.Resource; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Hooks.Internal; From 067c5535c26327cb9c2d9f71b1428ecf292c338a Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 22 Oct 2020 09:05:50 +0200 Subject: [PATCH 111/240] feat: ISet in serializer, 204/200 return values post/patch --- .../Services/WorkItemService.cs | 1 + .../JsonApiApplicationBuilder.cs | 1 + .../Controllers/BaseJsonApiController.cs | 21 +-- .../Controllers/JsonApiCommandController.cs | 4 +- .../Controllers/JsonApiController.cs | 4 +- .../Expressions/SparseFieldSetExpression.cs | 1 + .../EntityFrameworkCoreRepository.cs | 25 ++- .../Repositories/IResourceWriteRepository.cs | 1 - .../Resources/Annotations/HasManyAttribute.cs | 6 + .../Resources/IdentifiableExtensions.cs | 1 + .../Serialization/BaseDeserializer.cs | 25 ++- .../Services/JsonApiResourceService.cs | 4 +- .../Acceptance/ManyToManyTests.cs | 2 +- .../Acceptance/Spec/UpdatingDataTests.cs | 102 ++++++++++- .../Spec/UpdatingRelationshipsTests.cs | 169 ++++++++++++++++-- .../ModelStateValidationTests.cs | 16 +- .../ResourceInheritance/InheritanceTests.cs | 10 +- .../Common/DocumentParserTests.cs | 2 +- 18 files changed, 337 insertions(+), 58 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 4bbe4fec08..3be68eedee 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Dapper; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Configuration; diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 35768f220a..653d6cba10 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection.Emit; using JsonApiDotNetCore.Hooks.Internal; using JsonApiDotNetCore.Hooks.Internal.Discovery; using JsonApiDotNetCore.Hooks.Internal.Execution; diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index f8e1cb3d7a..79c52512d0 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -185,23 +184,22 @@ public virtual async Task PostAsync([FromBody] TResource resource /// The identifier of the primary resource. /// The relationship to add resources to. /// The set of resources to add to the relationship. - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds.ToHashSet(IdentifiableComparer.Instance)); + await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); // TODO: Silently ignore already-existing entries, causing duplicates. From json:api spec: // "If a client makes a POST request to a URL from a relationship link, the server MUST add the specified members to the relationship unless they are already present. If a given type and id is already in the relationship, the server MUST NOT add it again" // "Note: This matches the semantics of databases that use foreign keys for has-many relationships. Document-based storage should check the has-many relationship before appending to avoid duplicates." // "If all of the specified resources can be added to, or are already present in, the relationship then the server MUST return a successful response." // "Note: This approach ensures that a request is successful if the server’s state matches the requested state, and helps avoid pointless race conditions caused by multiple clients making the same changes to a relationship." - - // TODO: Should return 204 when relationship already exists (see json:api spec) + ensure we have a test covering this. - return Ok(); + + return NoContent(); } /// @@ -244,7 +242,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); await _setRelationship.SetRelationshipAsync(id, relationshipName, secondaryResourceIds); - return Ok(); + return NoContent(); } /// @@ -268,17 +266,16 @@ public virtual async Task DeleteAsync(TId id) /// The identifier of the primary resource. /// The relationship to remove resources from. /// The set of resources to remove from the relationship. - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) { _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds.ToHashSet(IdentifiableComparer.Instance)); - - // TODO: Should return 204 when relationship does not exist (see json:api spec) + ensure we have a test covering this. - return Ok(); + await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + + return NoContent(); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 18bb9a6aab..ea00374638 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -33,7 +33,7 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); /// @@ -53,7 +53,7 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 866d992b14..dcdbaee2aa 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -69,7 +69,7 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync( - TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); /// @@ -91,7 +91,7 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] IReadOnlyCollection secondaryResourceIds) + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index e9bb37b014..fdd5b5be54 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -10,6 +10,7 @@ namespace JsonApiDotNetCore.Queries.Expressions /// public class SparseFieldSetExpression : QueryExpression { + // todo: can we make a ISet of this, and later upgrade it to IReadOnlySet when aspnetcore 5 lands? public IReadOnlyCollection Attributes { get; } public SparseFieldSetExpression(IReadOnlyCollection attributes) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 70c8eeb3ac..477bb13063 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -142,8 +142,7 @@ public virtual async Task CreateAsync(TResource resource) _dbContext.Set().Add(resource); await SaveChangesAsync(); - - // Todo: why was this reverted? + FlushFromCache(resource); // This ensures relationships get reloaded from the database if they have @@ -223,14 +222,29 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource); var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, secondaryResourceIds); + + // todo: why has it been reverted to != again? bool hasChanges = newRightResources.Count != existingRightResources.Count; if (hasChanges) { @@ -250,10 +264,11 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet /// private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( - IEnumerable existingRightResources, IEnumerable resourcesToRemove) + IEnumerable existingRightResources, ISet resourcesToRemove) { var newRightResources = new HashSet(existingRightResources, IdentifiableComparer.Instance); newRightResources.ExceptWith(resourcesToRemove); + return newRightResources; } @@ -417,7 +432,7 @@ private void EnsureNoNullPrimaryKeys(object entity) if (propertyInfo.PropertyType == typeof(string)) { - propertyValue = ""; + propertyValue = string.Empty; } else if (Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null) { diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index f1057d2790..d5e4657380 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Repositories { diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 1555a24f7f..ee43e1fb65 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace JsonApiDotNetCore.Resources.Annotations { @@ -24,5 +25,10 @@ public HasManyAttribute() { Links = LinkTypes.All; } + + public virtual IEnumerable GetManyValue(object resource) + { + return (IEnumerable) base.GetValue(resource); + } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 1b62fe81af..d8ce5b0f01 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Reflection; namespace JsonApiDotNetCore.Resources diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 3c48e49a70..6ff9ae4de7 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -53,12 +53,18 @@ protected object DeserializeBody(string body) if (Document.IsManyData) { if (Document.ManyData.Count == 0) - return Array.Empty(); + { + return new HashSet(); + } - return Document.ManyData.Select(ParseResourceObject).ToArray(); + return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); + } + + if (Document.SingleData == null) + { + return null; } - if (Document.SingleData == null) return null; return ParseResourceObject(Document.SingleData); } @@ -101,20 +107,29 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio if (relationshipAttributes == null) throw new ArgumentNullException(nameof(relationshipAttributes)); if (relationshipValues == null || relationshipValues.Count == 0) + { return resource; + } var resourceProperties = resource.GetType().GetProperties(); foreach (var attr in relationshipAttributes) { var relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData); if (!relationshipIsProvided || !relationshipData.IsPopulated) + { continue; + } if (attr is HasOneAttribute hasOneAttribute) + { SetHasOneRelationship(resource, resourceProperties, hasOneAttribute, relationshipData); + } else + { SetHasManyRelationship(resource, (HasManyAttribute)attr, relationshipData); + } } + return resource; } @@ -145,7 +160,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) "If you are using Entity Framework Core, make sure the DbSet matches the expected resource name. " + "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); } - + var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); @@ -244,7 +259,7 @@ private void SetHasManyRelationship( relatedInstance.StringId = rio.Id; return relatedInstance; - }); + }).ToHashSet(IdentifiableComparer.Instance); var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, attr.Property.PropertyType); attr.SetValue(resource, convertedCollection, ResourceFactory); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1a911cb36f..ce6521a4c5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -383,7 +383,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } } - protected async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + private async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) { var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); primaryLayer.Sort = null; @@ -554,7 +554,7 @@ private List ToList(TResource resource) return new List { resource }; } - protected enum TopFieldSelection + private enum TopFieldSelection { AllAttributes, OnlyIdAttribute, diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 4eaa83c5a5..a971674fac 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -488,7 +488,7 @@ public async Task Can_Update_Many_To_Many_Through_Relationship_Link() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.True(HttpStatusCode.NoContent == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 91aad7cdd4..23393560f4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -328,14 +328,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'offsetDate' is not allowed. - Request body:"); } - + [Fact] public async Task Can_Patch_Resource() + { + // Arrange + var person = _personFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = person.StringId, + attributes = new Dictionary + { + ["lastName"] = "Johnson", + } + } + }; + + var route = "/api/v1/people/" + person.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var updated = await dbContext.People + .FirstAsync(t => t.Id == person.Id); + + updated.LastName.Should().Be("Johnson"); + }); + } + + [Fact] + public async Task Can_Patch_Resource_And_Get_Response_With_Side_Effects() { // Arrange var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); - + var currentStateOfAlwaysChangingValue = todoItem.AlwaysChangingValue; await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.TodoItems.Add(todoItem); @@ -367,9 +410,62 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes["description"].Should().Be("something else"); responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); + responseDocument.SingleData.Attributes["alwaysChangingValue"].Should().NotBe(currentStateOfAlwaysChangingValue); responseDocument.SingleData.Relationships.Should().ContainKey("owner"); responseDocument.SingleData.Relationships["owner"].SingleData.Should().BeNull(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var updated = await dbContext.TodoItems + .Include(t => t.Owner) + .SingleAsync(t => t.Id == todoItem.Id); + + updated.Description.Should().Be("something else"); + updated.Ordinal.Should().Be(1); + updated.AlwaysChangingValue.Should().NotBe(currentStateOfAlwaysChangingValue); + updated.Owner.Id.Should().Be(todoItem.Owner.Id); + }); + } + + [Fact] + public async Task Can_Patch_Resource_And_Hide_Side_Effects_With_Sparse_Field_Set_Selection() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = _personFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = todoItem.StringId, + attributes = new Dictionary + { + ["description"] = "something else", + ["ordinal"] = 1 + } + } + }; + + var route = $"/api/v1/todoItems/{todoItem.StringId}?fields=description,ordinal&"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be("something else"); + responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); + responseDocument.SingleData.Attributes.Count.Should().Be(2); + await _testContext.RunOnDatabaseAsync(async dbContext => { var updated = await dbContext.TodoItems @@ -382,7 +478,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: this test is flaky. + // TODO: This test is flaky. [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 931d179442..1218462927 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -561,7 +561,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -602,7 +602,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -617,7 +617,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_Delete_Relationship_By_Patching_Through_Relationship_Endpoint() + public async Task Can_Delete_ToOne_Relationship_By_Patching_Through_Relationship_Endpoint() { // Arrange var todoItem = _todoItemFaker.Generate(); @@ -640,7 +640,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -686,7 +686,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -699,9 +699,108 @@ await _testContext.RunOnDatabaseAsync(async dbContext => personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == otherTodoItem.Id); }); } + + [Fact] + public async Task Can_Add_Already_Related_Resource_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_It_Being_Readded() + { + // Arrange + var person = _personFaker.Generate(); + person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + + var alreadyRelatedTodoItem = person.TodoItems.ElementAt(0); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, alreadyRelatedTodoItem); + await dbContext.SaveChangesAsync(); + }); + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = alreadyRelatedTodoItem.StringId + }, + } + }; + + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(3); + }); + } + [Fact] - public async Task Can_Delete_From_To_ToMany_Relationship_Through_Relationship_Endpoint() + public async Task Can_Add_Duplicate_Resources_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_Them_Being_More_Than_Once() + { + // Arrange + var person = _personFaker.Generate(); + person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + + var duplicatedTodoResource = _todoItemFaker.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, duplicatedTodoResource); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = duplicatedTodoResource.StringId + }, + new + { + type = "todoItems", + id = duplicatedTodoResource.StringId + } + } + }; + + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(4); + personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == duplicatedTodoResource.Id); + + }); + } + + [Fact] + public async Task Can_Delete_From_ToMany_Relationship_Through_Relationship_Endpoint() { // Arrange var person = _personFaker.Generate(); @@ -723,11 +822,60 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "todoItems", id = todoItemToDelete.StringId + } + } + }; + + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == person.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(2); + personInDatabase.TodoItems.Should().NotContain(item => item.Id == todoItemToDelete.Id); + }); + } + + [Fact] + public async Task Can_Delete_Unrelated_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint_Without_Failing() + { + // Arrange + var person = _personFaker.Generate(); + person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + + var unrelatedTodoItems = _todoItemFaker.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(person, unrelatedTodoItems.First(), unrelatedTodoItems.Last()); + await dbContext.SaveChangesAsync(); + }); + + var todoItemToDelete = person.TodoItems.ElementAt(0); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = unrelatedTodoItems.ElementAt(0).StringId }, new { type = "todoItems", - id = "99999999" + id = unrelatedTodoItems.ElementAt(1).StringId } } }; @@ -738,7 +886,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -747,11 +895,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Where(p => p.Id == person.Id) .FirstAsync(); - personInDatabase.TodoItems.Should().HaveCount(2); - personInDatabase.TodoItems.Should().NotContain(item => item.Id == todoItemToDelete.Id); + personInDatabase.TodoItems.Should().HaveCount(3); }); } - + [Fact] public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index b80ef6e811..0fc3c7b817 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -315,9 +315,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeNull(); } [Fact] @@ -819,9 +819,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -886,9 +886,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeNull(); } [Fact] @@ -926,9 +926,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeNull(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 04ece493df..afe262d642 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -91,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -187,8 +187,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men @@ -285,8 +285,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 09e10ab8c3..c9952ae0a6 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -73,7 +73,7 @@ public void DeserializeResourceIdentifiers_ArrayData_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = (IIdentifiable[])_deserializer.Deserialize(body); + var result = (HashSet)_deserializer.Deserialize(body); // Assert Assert.Equal("1", result.First().StringId); From b7a90b9ff3184e6360b8bf46105f36da4b09552e Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 22 Oct 2020 12:37:55 +0200 Subject: [PATCH 112/240] chore: review --- .../Services/WorkItemService.cs | 1 - .../Configuration/JsonApiApplicationBuilder.cs | 1 - .../Controllers/BaseJsonApiController.cs | 6 ------ .../Queries/Expressions/SparseFieldSetExpression.cs | 2 +- .../Repositories/EntityFrameworkCoreRepository.cs | 2 +- .../Serialization/BaseDeserializer.cs | 2 +- .../Services/JsonApiResourceService.cs | 10 ++-------- 7 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 3d94b0fe62..8e87d51763 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using Dapper; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Configuration; diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 653d6cba10..35768f220a 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection.Emit; using JsonApiDotNetCore.Hooks.Internal; using JsonApiDotNetCore.Hooks.Internal.Discovery; using JsonApiDotNetCore.Hooks.Internal.Execution; diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index a4bc9e1e7e..bd0a8e289b 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -194,12 +194,6 @@ public virtual async Task PostRelationshipAsync(TId id, string re if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); - // TODO: Silently ignore already-existing entries, causing duplicates. From json:api spec: - // "If a client makes a POST request to a URL from a relationship link, the server MUST add the specified members to the relationship unless they are already present. If a given type and id is already in the relationship, the server MUST NOT add it again" - // "Note: This matches the semantics of databases that use foreign keys for has-many relationships. Document-based storage should check the has-many relationship before appending to avoid duplicates." - // "If all of the specified resources can be added to, or are already present in, the relationship then the server MUST return a successful response." - // "Note: This approach ensures that a request is successful if the server’s state matches the requested state, and helps avoid pointless race conditions caused by multiple clients making the same changes to a relationship." - return NoContent(); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index fdd5b5be54..81a50bc974 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Queries.Expressions /// public class SparseFieldSetExpression : QueryExpression { - // todo: can we make a ISet of this, and later upgrade it to IReadOnlySet when aspnetcore 5 lands? + // TODO: Once aspnetcore 5 is released, use IReadOnlySet here and in other places where functionally desired. public IReadOnlyCollection Attributes { get; } public SparseFieldSetExpression(IReadOnlyCollection attributes) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 267db72937..3b55928f76 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -422,7 +422,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio { var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); - // When assigning an related entity to a navigation property, it will be attached to change tracker. This fails + // When assigning a related entity to a navigation property it will be attached to change tracker. This fails // when that entity has null reference(s) for its primary key(s). EnsureNoNullPrimaryKeys(placeholderRightResource); diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 2016ae58e3..5147be8011 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -162,7 +162,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) "If you are using Entity Framework Core, make sure the DbSet matches the expected resource name. " + "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); } - + var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 95b74c07c9..367093b9a8 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -406,7 +406,8 @@ private async Task GetPrimaryResourceById(TId id, TopFieldSelection f if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - primaryLayer.Projection = GetPrimaryIdProjection(); + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + primaryLayer.Projection = new Dictionary {{idAttribute, null}}; } else if (fieldSelection == TopFieldSelection.AllAttributes && primaryLayer.Projection != null) { @@ -425,13 +426,6 @@ private async Task GetPrimaryResourceById(TId id, TopFieldSelection f return primaryResource; } - private Dictionary GetPrimaryIdProjection() - { - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - - return new Dictionary {{idAttribute, null}}; - } - private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) { var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); From c5d0816a99c4f6cbf50154b59b64019844bcce84 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 22 Oct 2020 12:41:14 +0200 Subject: [PATCH 113/240] fix: additional logging flaky test --- .../Acceptance/Spec/UpdatingDataTests.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index aeabf8963c..c2bc61bf55 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; @@ -13,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; +using Xunit.Abstractions; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -20,6 +22,7 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec public sealed class UpdatingDataTests : IClassFixture> { private readonly IntegrationTestContext _testContext; + private readonly ITestOutputHelper _testOutputHelper; private readonly Faker _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) @@ -30,9 +33,10 @@ public sealed class UpdatingDataTests : IClassFixture p.FirstName, f => f.Name.FirstName()) .RuleFor(p => p.LastName, f => f.Name.LastName()); - public UpdatingDataTests(IntegrationTestContext testContext) + public UpdatingDataTests(IntegrationTestContext testContext, ITestOutputHelper testOutputHelper) { _testContext = testContext; + _testOutputHelper = testOutputHelper; FakeLoggerFactory loggerFactory = null; @@ -510,7 +514,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + try + { + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + catch(Exception) + { + _testOutputHelper.WriteLine("What can we additionally log here to get insight in why this test is flaking irregularly?"); + throw; + } responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); From 825d7db0aaaa38d9419555ac2dccd5fbb2bd1907 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 22 Oct 2020 13:14:34 +0200 Subject: [PATCH 114/240] chore: made GetManyValue internal --- .../Resources/Annotations/HasManyAttribute.cs | 2 +- .../Resources/Annotations/HasManyThroughAttribute.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 90827e8e2c..ff4cb17617 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -26,7 +26,7 @@ public HasManyAttribute() Links = LinkTypes.All; } - public virtual IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) + internal virtual IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) { return (IEnumerable)base.GetValue(resource); } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 9b34575de1..9c07952184 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -111,13 +111,14 @@ public override object GetValue(object resource) return GetManyValue(resource, null); } - public override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) + internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) { if (resource == null) throw new ArgumentNullException(nameof(resource)); var throughEntities = ((IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty()).Cast().ToArray(); var rightResourcesAreLoaded = throughEntities.Any() && RightProperty.GetValue(throughEntities.First()) != null; + // Even if the right resources aren't loaded, we can still construct identifier objects using the id set on the through entity. var rightResources = rightResourcesAreLoaded ? throughEntities.Select(te => RightProperty.GetValue(te)).Cast() : throughEntities.Select(te => CreateRightResourceWithId(te, resourceFactory)); From a500a1d92489197879e9681dd030a9aff41f5d9b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 22 Oct 2020 22:55:43 +0200 Subject: [PATCH 115/240] Test improvements and cleanup. Removed TODO claiming that checking on empty body for HTTP/204 is redundant, because I'm convinced we should always assert on the response status and body in integration tests (especially if it is only a one-liner). And we should assert on the database contents for write operations. It is easy to produce a non-empty body with 204 status code, which violates the json:api spec. --- .../Resources/IdentifiableExtensions.cs | 1 - .../Acceptance/ManyToManyTests.cs | 428 +++++++++--------- .../Acceptance/Spec/UpdatingDataTests.cs | 24 +- .../Spec/UpdatingRelationshipsTests.cs | 235 ++++++---- .../CompositeKeys/CompositeKeyTests.cs | 4 +- .../ModelStateValidationTests.cs | 30 +- .../NoModelStateValidationTests.cs | 1 - .../ResourceDefinitionQueryCallbackTests.cs | 4 +- .../ResourceInheritance/InheritanceTests.cs | 33 +- .../SparseFieldSets/SparseFieldSetTests.cs | 26 +- ...reateResourceWithClientGeneratedIdTests.cs | 2 - .../CreateResourceWithRelationshipTests.cs | 8 +- .../Common/DocumentParserTests.cs | 1 - 13 files changed, 435 insertions(+), 362 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index d8ce5b0f01..1b62fe81af 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Reflection; namespace JsonApiDotNetCore.Resources diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index cfaa48b6c1..57de9b360c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -4,14 +4,11 @@ using System.Threading.Tasks; using Bogus; using FluentAssertions; -using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -29,238 +26,210 @@ public ManyToManyTests(IntegrationTestContext testContext _testContext = testContext; _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); + .RuleFor(a => a.LastName, f => f.Random.Words(2)); _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => _authorFaker.Generate()); + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Author, f => _authorFaker.Generate()); _tagFaker = new Faker() - .CustomInstantiator(f => new Tag()) - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - - FakeLoggerFactory loggerFactory = null; - - testContext.ConfigureLogging(options => - { - loggerFactory = new FakeLoggerFactory(); - - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Trace); - options.AddFilter((category, level) => level == LogLevel.Trace && - (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); - }); - - testContext.ConfigureServicesBeforeStartup(services => - { - if (loggerFactory != null) - { - services.AddSingleton(_ => loggerFactory); - } - }); + .CustomInstantiator(f => new Tag()) + .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } - + [Fact] public async Task Can_Get_HasManyThrough_Relationship_Through_Secondary_Endpoint() { // Arrange - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = tag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(article, tag, articleTag); + dbContext.ArticleTags.Add(existingArticleTag); await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/articles/{article.Id}/tags"; + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().ContainSingle(); - responseDocument.ManyData[0].Id.Should().Be(tag.StringId); + + responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Type.Should().Be("tags"); - responseDocument.ManyData[0].Attributes["name"].Should().Be(tag.Name); + responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); + responseDocument.ManyData[0].Attributes["name"].Should().Be(existingArticleTag.Tag.Name); } - + [Fact] public async Task Can_Get_HasManyThrough_Through_Relationship_Endpoint() { // Arrange - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = tag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(article, tag, articleTag); + dbContext.ArticleTags.Add(existingArticleTag); await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; - + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().ContainSingle(); - responseDocument.ManyData[0].Id.Should().Be(tag.StringId); + + responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Type.Should().Be("tags"); + responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); responseDocument.ManyData[0].Attributes.Should().BeNull(); } - + [Fact] public async Task Can_Create_Resource_With_HasManyThrough_Relationship() { // Arrange - var tag = _tagFaker.Generate(); - var author = _authorFaker.Generate(); + var existingTag = _tagFaker.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(tag, author); + dbContext.Tags.Add(existingTag); await dbContext.SaveChangesAsync(); }); - + var requestBody = new { data = new { type = "articles", - attributes = new Dictionary + relationships = new { - {"caption", "An article with relationships"} - }, - relationships = new Dictionary - { - { "author", new { - data = new - { - type = "authors", - id = author.StringId - } - } }, - { "tags", new { - data = new dynamic[] + tags = new + { + data = new[] { - new { + new + { type = "tags", - id = tag.StringId + id = existingTag.StringId } } - } } + } } } }; - - var route = $"/api/v1/articles"; + + var route = "/api/v1/articles"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - + + responseDocument.SingleData.Should().NotBeNull(); + var newArticleId = int.Parse(responseDocument.SingleData.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == int.Parse(responseDocument.SingleData.Id)); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == newArticleId); - persistedArticle.ArticleTags.Should().ContainSingle(); - persistedArticle.ArticleTags.First().TagId.Should().Be(tag.Id); + articleInDatabase.ArticleTags.Should().HaveCount(1); + articleInDatabase.ArticleTags.First().TagId.Should().Be(existingTag.Id); }); } - + [Fact] public async Task Can_Set_HasManyThrough_Relationship_Through_Primary_Endpoint() { // Arrange - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = firstTag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - var secondTag = _tagFaker.Generate(); - + + var existingTag = _tagFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(firstTag, secondTag, article, articleTag); + dbContext.AddRange(existingArticleTag, existingTag); await dbContext.SaveChangesAsync(); }); - + var requestBody = new { data = new { type = "articles", - id = article.StringId, - relationships = new Dictionary + id = existingArticleTag.Article.StringId, + relationships = new { - { "tags", new { - data = new [] { new + tags = new + { + data = new[] { - type = "tags", - id = secondTag.StringId - } } - } } + new + { + type = "tags", + id = existingTag.StringId + } + } + } } } }; - var route = $"/api/v1/articles/{article.Id}"; + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == article.Id); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - persistedArticle.ArticleTags.Should().ContainSingle(); - persistedArticle.ArticleTags.First().TagId.Should().Be(secondTag.Id); + articleInDatabase.ArticleTags.Should().HaveCount(1); + articleInDatabase.ArticleTags.Single().TagId.Should().Be(existingTag.Id); }); } - + [Fact] public async Task Can_Set_With_Overlap_To_HasManyThrough_Relationship_Through_Primary_Endpoint() { // Arrange - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = firstTag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - var secondTag = _tagFaker.Generate(); + + var existingTag = _tagFaker.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(firstTag, secondTag, article, articleTag); + dbContext.AddRange(existingArticleTag, existingTag); await dbContext.SaveChangesAsync(); }); @@ -269,65 +238,69 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "articles", - id = article.StringId, - relationships = new Dictionary + id = existingArticleTag.Article.StringId, + relationships = new { - { "tags", new { - data = new [] { new + tags = new + { + data = new[] { - type = "tags", - id = firstTag.StringId - }, new - { - type = "tags", - id = secondTag.StringId - } } - } } + new + { + type = "tags", + id = existingArticleTag.Tag.StringId + }, + new + { + type = "tags", + id = existingTag.StringId + } + } + } } } }; - var route = $"/api/v1/articles/{article.Id}"; - + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == article.Id); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - persistedArticle.ArticleTags.Should().HaveCount(2); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == firstTag.Id); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == secondTag.Id); + articleInDatabase.ArticleTags.Should().HaveCount(2); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticleTag.Tag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); }); } - + [Fact] public async Task Can_Set_HasManyThrough_Relationship_Through_Relationships_Endpoint() { // Arrange - var tag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = tag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - var secondTag = _tagFaker.Generate(); - + + var existingTag = _tagFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(tag, secondTag, article, articleTag); + dbContext.AddRange(existingArticleTag, existingTag); await dbContext.SaveChangesAsync(); }); - + var requestBody = new { data = new[] @@ -335,49 +308,50 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "tags", - id = secondTag.StringId + id = existingTag.StringId } } }; - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == article.Id); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - persistedArticle.ArticleTags.Should().ContainSingle(); - persistedArticle.ArticleTags.First().TagId.Should().Be(secondTag.Id); + articleInDatabase.ArticleTags.Should().HaveCount(1); + articleInDatabase.ArticleTags.Single().TagId.Should().Be(existingTag.Id); }); } - + [Fact] public async Task Can_Add_To_HasManyThrough_Relationship_Through_Relationships_Endpoint() { // Arrange - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = firstTag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - var secondTag = _tagFaker.Generate(); - + + var existingTag = _tagFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(firstTag, secondTag, article, articleTag); + dbContext.AddRange(existingArticleTag, existingTag); await dbContext.SaveChangesAsync(); }); - + var requestBody = new { data = new[] @@ -385,48 +359,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "tags", - id = secondTag.StringId + id = existingTag.StringId } } }; - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == article.Id); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - persistedArticle.ArticleTags.Should().HaveCount(2); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == firstTag.Id); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == secondTag.Id); + articleInDatabase.ArticleTags.Should().HaveCount(2); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticleTag.Tag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); }); } - + [Fact] public async Task Can_Add_Already_Related_Resource_Without_It_Being_Readded_To_HasManyThrough_Relationship_Through_Relationships_Endpoint() { // Arrange - var article = _articleFaker.Generate(); - var firstTag = _tagFaker.Generate(); - var secondTag = _tagFaker.Generate(); - article.ArticleTags = new HashSet {new ArticleTag {Article = article, Tag = firstTag}, new ArticleTag {Article = article, Tag = secondTag} }; + var existingArticle = _articleFaker.Generate(); + existingArticle.ArticleTags = new HashSet + { + new ArticleTag {Tag = _tagFaker.Generate()}, + new ArticleTag {Tag = _tagFaker.Generate()} + }; + + var existingTag = _tagFaker.Generate(); - var thirdTag = _tagFaker.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(firstTag, secondTag, article, thirdTag); + dbContext.AddRange(existingArticle, existingTag); await dbContext.SaveChangesAsync(); }); - + var requestBody = new { data = new[] @@ -434,57 +412,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "tags", - id = secondTag.StringId + id = existingArticle.ArticleTags.ElementAt(1).Tag.StringId }, new { type = "tags", - id = thirdTag.StringId + id = existingTag.StringId } } }; - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; + var route = $"/api/v1/articles/{existingArticle.StringId}/relationships/tags"; // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == article.Id); - - persistedArticle.ArticleTags.Should().HaveCount(3); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == firstTag.Id); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == secondTag.Id); - persistedArticle.ArticleTags.Should().ContainSingle(at => at.TagId == thirdTag.Id); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == existingArticle.Id); + + articleInDatabase.ArticleTags.Should().HaveCount(3); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticle.ArticleTags.ElementAt(0).Tag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticle.ArticleTags.ElementAt(1).Tag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); }); } - + [Fact] public async Task Can_Delete_From_HasManyThrough_Relationship_Through_Relationships_Endpoint() { // Arrange - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = tag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - article.ArticleTags = new HashSet {articleTag}; - await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(tag, article, articleTag); + dbContext.ArticleTags.AddRange(existingArticleTag); await dbContext.SaveChangesAsync(); }); - + var requestBody = new { data = new[] @@ -492,26 +468,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "tags", - id = tag.StringId + id = existingArticleTag.Tag.StringId } } }; - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; // Act - var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var persistedArticle = await dbContext.Articles - .Include(a => a.ArticleTags) - .FirstAsync(a => a.Id == article.Id); + var articleInDatabase = await dbContext.Articles + .Include(article => article.ArticleTags) + .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - persistedArticle.ArticleTags.Should().BeEmpty(); + articleInDatabase.ArticleTags.Should().BeEmpty(); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index c2bc61bf55..0f5cde7f58 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -332,12 +332,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'offsetDate' is not allowed. - Request body:"); } - + [Fact] public async Task Can_Patch_Resource() { // Arrange var person = _personFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.People.Add(person); @@ -360,11 +361,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/people/" + person.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var updated = await dbContext.People @@ -381,6 +384,7 @@ public async Task Can_Patch_Resource_And_Get_Response_With_Side_Effects() var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); var currentStateOfAlwaysChangingValue = todoItem.AlwaysChangingValue; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.TodoItems.Add(todoItem); @@ -413,14 +417,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["description"].Should().Be("something else"); responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); responseDocument.SingleData.Attributes["alwaysChangingValue"].Should().NotBe(currentStateOfAlwaysChangingValue); - responseDocument.SingleData.Relationships.Should().ContainKey("owner"); responseDocument.SingleData.Relationships["owner"].SingleData.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { var updated = await dbContext.TodoItems .Include(t => t.Owner) - .SingleAsync(t => t.Id == todoItem.Id); + .FirstAsync(t => t.Id == todoItem.Id); updated.Description.Should().Be("something else"); updated.Ordinal.Should().Be(1); @@ -428,13 +431,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => updated.Owner.Id.Should().Be(todoItem.Owner.Id); }); } - + [Fact] public async Task Can_Patch_Resource_With_Side_Effects_And_Apply_Sparse_Field_Set_Selection() { // Arrange var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.TodoItems.Add(todoItem); @@ -455,7 +459,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/api/v1/todoItems/{todoItem.StringId}?fields=description,ordinal&"; + var route = $"/api/v1/todoItems/{todoItem.StringId}?fields=description,ordinal"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -464,15 +468,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(2); responseDocument.SingleData.Attributes["description"].Should().Be("something else"); responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); - responseDocument.SingleData.Attributes.Count.Should().Be(2); await _testContext.RunOnDatabaseAsync(async dbContext => { var updated = await dbContext.TodoItems .Include(t => t.Owner) - .SingleAsync(t => t.Id == todoItem.Id); + .FirstAsync(t => t.Id == todoItem.Id); updated.Description.Should().Be("something else"); updated.Ordinal.Should().Be(1); @@ -480,6 +484,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Add test(s) that save a relationship, then return its data via include. + // TODO: This test is flaky. [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() @@ -582,7 +588,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var updated = await dbContext.TodoItems .Include(t => t.Owner) - .SingleAsync(t => t.Id == todoItem.Id); + .FirstAsync(t => t.Id == todoItem.Id); updated.Description.Should().Be("Something else"); updated.Owner.Id.Should().Be(person.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index bf18eb81e1..e88ac77b15 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -57,8 +57,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new[] { - new {type = "todoItems", id = todoItem.StringId}, - new {type = "todoItems", id = otherTodoItem.StringId} + new + { + type = "todoItems", + id = todoItem.StringId + }, + new + { + type = "todoItems", + id = otherTodoItem.StringId + } } } } @@ -108,7 +116,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { ["dependentOnTodo"] = new { - data = new {type = "todoItems", id = todoItem.StringId} + data = new + { + type = "todoItems", + id = todoItem.StringId + } } } } @@ -139,7 +151,7 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi // Arrange var todoItem = _todoItemFaker.Generate(); var otherTodoItem = _todoItemFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.TodoItems.AddRange(todoItem, otherTodoItem); @@ -156,14 +168,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { ["dependentOnTodo"] = new { - data = new {type = "todoItems", id = todoItem.StringId} + data = new + { + type = "todoItems", + id = todoItem.StringId + } }, ["childrenTodos"] = new { data = new[] { - new {type = "todoItems", id = todoItem.StringId}, - new {type = "todoItems", id = otherTodoItem.StringId} + new + { + type = "todoItems", + id = todoItem.StringId + }, + new + { + type = "todoItems", + id = otherTodoItem.StringId + } } } } @@ -198,7 +222,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() var person2 = _personFaker.Generate(); person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.People.AddRange(person1, person2); @@ -250,7 +274,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .ToListAsync(); personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().HaveCount(1); - + var person2InDatabase = personsInDatabase.Single(person => person.Id == person2.Id); person2InDatabase.TodoItems.Should().HaveCount(2); person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(0).Id); @@ -293,8 +317,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new[] { - new {type = "todoItems", id = todoItem1.StringId}, - new {type = "todoItems", id = todoItem2.StringId} + new + { + type = "todoItems", + id = todoItem1.StringId + }, + new + { + type = "todoItems", + id = todoItem2.StringId + } } } } @@ -393,7 +425,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { owner = new { - data = new { type = "people", id = person.StringId} + data = new + { + type = "people", + id = person.StringId + } } } } @@ -418,7 +454,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => todoItemInDatabase.Owner.Id.Should().Be(person.Id); }); } - + [Fact] public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() { @@ -501,7 +537,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { ["passport"] = new { - data = new {type = "passports", id = passport.StringId} + data = new + { + type = "passports", + id = passport.StringId + } } } } @@ -534,7 +574,7 @@ public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - + var otherTodoItem = _todoItemFaker.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -558,11 +598,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People @@ -581,7 +623,7 @@ public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() // Arrange var person = _personFaker.Generate(); var otherTodoItem = _todoItemFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.AddRange(person, otherTodoItem); @@ -592,18 +634,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "people", id = person.StringId + type = "people", + id = person.StringId } }; var route = $"/api/v1/todoItems/{otherTodoItem.StringId}/relationships/owner"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems @@ -622,7 +667,7 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Through_Relationship // Arrange var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.TodoItems.Add(todoItem); @@ -637,11 +682,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/owner"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems @@ -659,9 +706,9 @@ public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - + var otherTodoItem = _todoItemFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.AddRange(person, otherTodoItem); @@ -683,11 +730,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People @@ -699,19 +748,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == otherTodoItem.Id); }); } - + [Fact] public async Task Can_Add_Already_Related_Resource_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_It_Being_Readded() - { + { // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - var alreadyRelatedTodoItem = person.TodoItems.ElementAt(0); - await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(person, alreadyRelatedTodoItem); + dbContext.AddRange(person); await dbContext.SaveChangesAsync(); }); @@ -722,7 +769,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "todoItems", - id = alreadyRelatedTodoItem.StringId + id = person.TodoItems.ElementAt(0).StringId }, } }; @@ -730,11 +777,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People @@ -745,19 +794,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => personInDatabase.TodoItems.Should().HaveCount(3); }); } - + [Fact] - public async Task Can_Add_Duplicate_Resources_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_Them_Being_More_Than_Once() - { + public async Task Can_Add_Duplicate_Resources_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_Them_Being_Added_More_Than_Once() + { // Arrange - var person = _personFaker.Generate(); - person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + var existingPerson = _personFaker.Generate(); + existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + + var existingTodoItem = _todoItemFaker.Generate(); - var duplicatedTodoResource = _todoItemFaker.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(person, duplicatedTodoResource); + dbContext.AddRange(existingPerson, existingTodoItem); await dbContext.SaveChangesAsync(); }); @@ -768,34 +817,35 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "todoItems", - id = duplicatedTodoResource.StringId + id = existingTodoItem.StringId }, new { type = "todoItems", - id = duplicatedTodoResource.StringId + id = existingTodoItem.StringId } } }; - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) + .Where(p => p.Id == existingPerson.Id) .FirstAsync(); personInDatabase.TodoItems.Should().HaveCount(4); - personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == duplicatedTodoResource.Id); - + personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == existingTodoItem.Id); }); } @@ -803,16 +853,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_Delete_From_ToMany_Relationship_Through_Relationship_Endpoint() { // Arrange - var person = _personFaker.Generate(); - person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - + var existingPerson = _personFaker.Generate(); + existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.People.Add(person); + dbContext.People.Add(existingPerson); await dbContext.SaveChangesAsync(); }); - - var todoItemToDelete = person.TodoItems.ElementAt(0); + + var todoItemToDelete = existingPerson.TodoItems.ElementAt(0); var requestBody = new { @@ -826,19 +876,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; // Act - var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) + .Where(p => p.Id == existingPerson.Id) .FirstAsync(); personInDatabase.TodoItems.Should().HaveCount(2); @@ -850,18 +902,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_Delete_Unrelated_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint_Without_Failing() { // Arrange - var person = _personFaker.Generate(); - person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - + var existingPerson = _personFaker.Generate(); + existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + var unrelatedTodoItems = _todoItemFaker.Generate(2).ToHashSet(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(person, unrelatedTodoItems.First(), unrelatedTodoItems.Last()); + dbContext.AddRange(existingPerson); + dbContext.AddRange(unrelatedTodoItems); await dbContext.SaveChangesAsync(); }); - - var todoItemToDelete = person.TodoItems.ElementAt(0); var requestBody = new { @@ -880,32 +931,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; // Act - var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) + .Where(p => p.Id == existingPerson.Id) .FirstAsync(); personInDatabase.TodoItems.Should().HaveCount(3); }); } - + [Fact] public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { // Arrange var todoItem = _todoItemFaker.Generate(); var person = _personFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.AddRange(todoItem, person); @@ -924,14 +977,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new[] { - new {type = "people", id = person.StringId}, - new {type = "people", id = "900000"}, - new {type = "people", id = "900001"} + new + { + type = "people", + id = person.StringId + }, + new + { + type = "people", + id = "900000" + }, + new + { + type = "people", + id = "900001" + } } }, ["parentTodo"] = new { - data = new {type = "todoItems", id = "900002"} + data = new + { + type = "todoItems", + id = "900002" + } } } } @@ -965,7 +1034,7 @@ public async Task Fails_When_Patching_On_Relationships_Endpoint_With_Missing_Pri { // Arrange var person = _personFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.People.Add(person); @@ -976,7 +1045,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "people", id = person.StringId + type = "people", + id = person.StringId } }; @@ -1000,7 +1070,7 @@ public async Task Fails_When_Patching_On_Relationships_Endpoint_With_Unknown_Rel // Arrange var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.AddRange(person, todoItem); @@ -1011,7 +1081,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "people", id = person.StringId + type = "people", + id = person.StringId } }; @@ -1048,15 +1119,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { new { - type = "people", id = person.StringId + type = "people", + id = person.StringId }, new { - type = "people", id = "9999000" + type = "people", + id = "9999000" }, new { - type = "people", id = "9999111" + type = "people", + id = "9999111" } } }; @@ -1070,7 +1144,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); responseDocument.Errors.Should().HaveCount(2); - + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '9999000' being assigned to relationship 'stakeHolders' does not exist."); @@ -1096,7 +1170,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "people", id = "9999999" + type = "people", + id = "9999999" } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 9c143bcc38..692f96f441 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -227,7 +227,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines .Include(e => e.Car) - .SingleAsync(e => e.Id == engine.Id); + .FirstAsync(e => e.Id == engine.Id); engineInDatabase.Car.Should().BeNull(); }); @@ -289,7 +289,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines .Include(e => e.Car) - .SingleAsync(e => e.Id == engine.Id); + .FirstAsync(e => e.Id == engine.Id); engineInDatabase.Car.Should().NotBeNull(); engineInDatabase.Car.Id.Should().Be(car.StringId); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index ed3ac59532..c735f980fa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -311,10 +311,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/systemDirectories/{directory.StringId}/relationships/files"; // Act - var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -653,10 +655,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -710,10 +714,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -762,10 +768,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -807,17 +815,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId + "/relationships/parent"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var directoryInDatabase = await dbContext.Directories .Include(d => d.Parent) .Where(d => d.Id == directory.Id) - .SingleAsync(); + .FirstAsync(); directoryInDatabase.Parent.Id.Should().Be(otherParent.Id); }); @@ -872,10 +882,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/systemDirectories/" + directory.StringId + "/relationships/files"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } [Fact] @@ -910,10 +922,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/systemDirectories/{directory.StringId}/relationships/files"; // Act - var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 31096c4423..33619cb477 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index ac3c661242..c442d7c33a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -333,8 +333,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); - responseDocument.SingleData.Attributes.Should().NotContainKey("percentageComplete"); responseDocument.SingleData.Attributes["status"].Should().Be("5% completed."); } @@ -396,8 +396,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); - responseDocument.SingleData.Attributes.Should().NotContainKey("riskLevel"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 119eb512cb..0a498da345 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -57,13 +57,12 @@ public async Task Can_create_resource_with_inherited_attributes() responseDocument.SingleData.Attributes["isRetired"].Should().Be(man.IsRetired); responseDocument.SingleData.Attributes["hasBeard"].Should().Be(man.HasBeard); - var newManId = responseDocument.SingleData.Id; - newManId.Should().NotBeNullOrEmpty(); + var newManId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men - .SingleAsync(m => m.Id == int.Parse(newManId)); + .FirstAsync(m => m.Id == newManId); manInDatabase.FamilyName.Should().Be(man.FamilyName); manInDatabase.IsRetired.Should().Be(man.IsRetired); @@ -113,12 +112,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + var newManId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men .Include(man => man.HealthInsurance) - .SingleAsync(man => man.Id == int.Parse(responseDocument.SingleData.Id)); + .FirstAsync(man => man.Id == newManId); manInDatabase.HealthInsurance.Should().BeOfType(); manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); @@ -152,16 +152,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/men/{existingMan.StringId}/relationships/healthInsurance"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men .Include(man => man.HealthInsurance) - .SingleAsync(man => man.Id == existingMan.Id); + .FirstAsync(man => man.Id == existingMan.Id); manInDatabase.HealthInsurance.Should().BeOfType(); manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); @@ -219,15 +221,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); - - var newManId = responseDocument.SingleData.Id; - newManId.Should().NotBeNullOrEmpty(); + var newManId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men .Include(man => man.Parents) - .SingleAsync(man => man.Id == int.Parse(newManId)); + .FirstAsync(man => man.Id == newManId); manInDatabase.Parents.Should().HaveCount(2); manInDatabase.Parents.Should().ContainSingle(human => human is Man); @@ -271,16 +271,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/men/{existingChild.StringId}/relationships/parents"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var manInDatabase = await dbContext.Men .Include(man => man.Parents) - .SingleAsync(man => man.Id == existingChild.Id); + .FirstAsync(man => man.Id == existingChild.Id); manInDatabase.Parents.Should().HaveCount(2); manInDatabase.Parents.Should().ContainSingle(human => human is Man); @@ -339,11 +341,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + var newManId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems - .Where(favorite => favorite.Human.Id == int.Parse(responseDocument.SingleData.Id)) + .Where(favorite => favorite.Human.Id == newManId) .Select(favorite => favorite.ContentItem) .ToListAsync(); @@ -389,11 +392,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/men/{existingMan.StringId}/relationships/favoriteContent"; // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index d98c85f20f..4ed606caf6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -86,8 +86,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); - responseDocument.ManyData[0].Attributes.Should().NotContainKey("url"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Caption.Should().Be(article.Caption); @@ -124,8 +124,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["url"].Should().Be(article.Url); - responseDocument.SingleData.Attributes.Should().NotContainKey("caption"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Url.Should().Be(article.Url); @@ -169,8 +169,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Id.Should().Be(blog.Articles[0].StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); - responseDocument.ManyData[0].Attributes.Should().NotContainKey("url"); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -217,9 +217,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); responseDocument.Included[0].Attributes["businessEmail"].Should().Be(article.Author.BusinessEmail); - responseDocument.Included[0].Attributes.Should().NotContainKey("firstName"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -268,8 +268,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["lastName"].Should().Be(author.LastName); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["caption"].Should().Be(author.Articles[0].Caption); - responseDocument.Included[0].Attributes.Should().NotContainKey("url"); var authorCaptured = (Author) store.Resources.Should().ContainSingle(x => x is Author).And.Subject.Single(); authorCaptured.Id.Should().Be(author.Id); @@ -323,8 +323,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); - responseDocument.Included[0].Attributes.Should().NotContainKey("url"); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -376,8 +376,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["color"].Should().Be(article.ArticleTags.Single().Tag.Color.ToString("G")); - responseDocument.Included[0].Attributes.Should().NotContainKey("name"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -432,19 +432,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(blog.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); - responseDocument.SingleData.Attributes.Should().NotContainKey("companyName"); responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); - responseDocument.Included[0].Attributes.Should().NotContainKey("dateOfBirth"); responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.Included[1].Attributes.Should().HaveCount(1); responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); - responseDocument.Included[1].Attributes.Should().NotContainKey("url"); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -504,8 +504,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(blog.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); - responseDocument.SingleData.Attributes.Should().NotContainKey("companyName"); responseDocument.Included.Should().HaveCount(2); @@ -555,8 +555,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); - responseDocument.ManyData[0].Attributes.Should().NotContainKey("url"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -652,8 +652,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["calculatedValue"].Should().Be(todoItem.CalculatedValue); - responseDocument.SingleData.Attributes.Should().NotContainKey("description"); var todoItemCaptured = (TodoItem) store.Resources.Should().ContainSingle(x => x is TodoItem).And.Subject.Single(); todoItemCaptured.CalculatedValue.Should().Be(todoItem.CalculatedValue); diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index c736f6b410..1ef7c962af 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -152,8 +152,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // todo: I think this is redundant. It's not possible to pass a body to the IActionResult NoContent() method in the controllers. - // So we're basically testing asp.net cores internals rather than JADNC. responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 4c8177caf9..b03873584b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -254,8 +254,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); - responseDocument.SingleData.Attributes.Should().NotContainKey("priority"); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); @@ -585,8 +585,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().OnlyContain(p => p.Type == "userAccounts"); responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[0].StringId); responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(p => p.Attributes.Count == 1); responseDocument.Included.Should().OnlyContain(p => p.Attributes["firstName"] != null); - responseDocument.Included.Should().OnlyContain(p => !p.Attributes.ContainsKey("lastName")); var newWorkItemId = responseDocument.SingleData.Id; newWorkItemId.Should().NotBeNullOrEmpty(); @@ -733,8 +733,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); - responseDocument.SingleData.Attributes.Should().NotContainKey("description"); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); @@ -743,8 +743,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[0].StringId); responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[1].StringId); responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[2].StringId); + responseDocument.Included.Should().OnlyContain(p => p.Attributes.Count == 1); responseDocument.Included.Should().OnlyContain(p => p.Attributes["text"] != null); - responseDocument.Included.Should().OnlyContain(p => !p.Attributes.ContainsKey("isBuiltIn")); var newWorkItemId = responseDocument.SingleData.Id; newWorkItemId.Should().NotBeNullOrEmpty(); diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 83382f426f..da0c680b87 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.ComponentModel.Design; using System.Linq; From 7126be73d38dacdca9cacfd6a515b2b225bd1667 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 22 Oct 2020 23:02:31 +0200 Subject: [PATCH 116/240] Replaced TODO with GitHub issue https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/864. --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 2 +- .../Queries/Expressions/SparseFieldSetExpression.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index bd0a8e289b..154838f8cd 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -269,7 +269,7 @@ public virtual async Task DeleteRelationshipAsync(TId id, string if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); - + return NoContent(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index 81a50bc974..e9bb37b014 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -10,7 +10,6 @@ namespace JsonApiDotNetCore.Queries.Expressions /// public class SparseFieldSetExpression : QueryExpression { - // TODO: Once aspnetcore 5 is released, use IReadOnlySet here and in other places where functionally desired. public IReadOnlyCollection Attributes { get; } public SparseFieldSetExpression(IReadOnlyCollection attributes) From b35b1a26f71e0283ff3c983c9f060efdc99fe04b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 00:08:50 +0200 Subject: [PATCH 117/240] review --- .../EntityFrameworkCoreRepository.cs | 69 +++++++++++-------- .../Annotations/HasManyThroughAttribute.cs | 18 +++-- .../Annotations/RelationshipAttribute.cs | 4 +- .../Serialization/BaseDeserializer.cs | 36 +++++----- .../Services/JsonApiResourceService.cs | 3 +- .../Acceptance/Spec/UpdatingDataTests.cs | 30 ++++---- ...reateResourceWithClientGeneratedIdTests.cs | 4 +- .../Common/DocumentParserTests.cs | 4 +- 8 files changed, 97 insertions(+), 71 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3b55928f76..e3f6155980 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -12,6 +13,7 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -158,7 +160,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds) + private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, TId primaryResourceId, ISet secondaryResourceIds) { - var primaryResource = await _dbContext.Set() + // TODO: This is a no-go, because it loads the complete set of related entities, which can be massive. + // Instead, it should only load the subset of related entities that is in secondaryResourceIds, and the deduce what still needs to be added. + + var primaryResource = await _dbContext.Set() + // TODO: Why use AsNoTracking() here? We're not doing that anywhere else. .AsNoTracking() .Where(r => r.Id.Equals(primaryResourceId)) .Include(hasManyThroughRelationship.ThroughPropertyName) .FirstAsync(); - + var existingRightResources = hasManyThroughRelationship.GetManyValue(primaryResource, _resourceFactory).ToHashSet(IdentifiableComparer.Instance); secondaryResourceIds.ExceptWith(existingRightResources); } - + private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) { EntityEntry entityEntry = _dbContext.Entry(resource); @@ -413,18 +419,18 @@ private bool IsOneToOneRelationship(RelationshipAttribute relationship) /// /// If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to /// set it to null by just assigning null to the navigation property and marking it as modified. - /// Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too but without nulling its value. - /// One way to work around this is by loading the relationship before nulling it. Another approach as done in this method is - /// tricking the change tracker into recognising the null assignment by first assigning a placeholder entity to the navigation property, and then - /// nulling it out. + /// Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too but without setting its value to null. + /// One way to work around this is by loading the relationship before setting it to null. Another approach (as done in this method) is + /// tricking the change tracker into recognizing the null assignment by first assigning a placeholder entity to the navigation property, and then + /// setting it to null. /// private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relationship, TResource leftResource) { var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); - // When assigning a related entity to a navigation property it will be attached to change tracker. This fails - // when that entity has null reference(s) for its primary key(s). - EnsureNoNullPrimaryKeys(placeholderRightResource); + // When assigning a related entity to a navigation property, it will be attached to the change tracker. + // This fails when that entity has null reference(s) for its primary key(s). + EnsurePrimaryKeyPropertiesAreNotNull(placeholderRightResource); relationship.SetValue(leftResource, placeholderRightResource, _resourceFactory); _dbContext.Entry(leftResource).DetectChanges(); @@ -432,33 +438,42 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio _dbContext.Entry(placeholderRightResource).State = EntityState.Detached; } - private void EnsureNoNullPrimaryKeys(object entity) + private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) { var primaryKey = _dbContext.Entry(entity).Metadata.FindPrimaryKey(); if (primaryKey != null) { - foreach (var propertyMeta in primaryKey.Properties) + foreach (var property in primaryKey.Properties) { - var propertyInfo = propertyMeta.PropertyInfo; - object propertyValue = null; - - if (propertyInfo.PropertyType == typeof(string)) - { - propertyValue = string.Empty; - } - else if (Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null) - { - propertyValue = propertyInfo.PropertyType.GetGenericArguments()[0]; - } - + var propertyValue = TryGetValueForProperty(property.PropertyInfo); if (propertyValue != null) { - propertyInfo.SetValue(entity, propertyValue); + property.PropertyInfo.SetValue(entity, propertyValue); } } } } + private static object TryGetValueForProperty(PropertyInfo propertyInfo) + { + if (propertyInfo.PropertyType == typeof(string)) + { + return string.Empty; + } + + if (Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null) + { + // TODO: This looks wrong -- we're returning a System.Type in this case. + // I would expect it needs to return the default value of the underlying type. + return propertyInfo.PropertyType.GetGenericArguments()[0]; + } + + // TODO: I'm assuming that returning null for a non-nullable value type is okay here. + // If so, then we should throw in case we find a non-string reference type. + + return null; + } + private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) { if (valueToAssign is IReadOnlyCollection rightResourcesInToManyRelationship) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 9c07952184..3d389bc16c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -108,20 +108,28 @@ public override object GetValue(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); + // TODO: Passing null for the resourceFactory parameter is wrong here. Instead, GetManyValue() should properly throw when null is passed in. return GetManyValue(resource, null); } - + internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) { + // TODO: This method contains surprising code: Instead of returning the contents of a collection, + // it modifies data and performs logic that is highly specific to what EntityFrameworkCoreRepository needs. + // This method is not reusable at all, it should not be concerned if resources are loaded, so should be moved into the caller instead. + // After moving the code, the unneeded copying into new collections multiple times can be removed too. + if (resource == null) throw new ArgumentNullException(nameof(resource)); - var throughEntities = ((IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty()).Cast().ToArray(); + var value = ThroughProperty.GetValue(resource); + + var throughEntities = value == null ? Array.Empty() : ((IEnumerable)value).Cast().ToArray(); var rightResourcesAreLoaded = throughEntities.Any() && RightProperty.GetValue(throughEntities.First()) != null; - // Even if the right resources aren't loaded, we can still construct identifier objects using the id set on the through entity. + // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. var rightResources = rightResourcesAreLoaded - ? throughEntities.Select(te => RightProperty.GetValue(te)).Cast() - : throughEntities.Select(te => CreateRightResourceWithId(te, resourceFactory)); + ? throughEntities.Select(entity => RightProperty.GetValue(entity)).Cast() + : throughEntities.Select(entity => CreateRightResourceWithId(entity, resourceFactory)); return (IEnumerable)TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index 4b9e02410d..fea89da90d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -86,7 +86,7 @@ public LinkTypes Links public bool CanInclude { get; set; } = true; /// - /// Gets the value of the resource property this attributes was declared on. + /// Gets the value of the resource property this attribute was declared on. /// public virtual object GetValue(object resource) { @@ -96,7 +96,7 @@ public virtual object GetValue(object resource) } /// - /// Sets the value of the resource property this attributes was declared on. + /// Sets the value of the resource property this attribute was declared on. /// public virtual void SetValue(object resource, object newValue, IResourceFactory resourceFactory) { diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 5147be8011..98127132b8 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -54,13 +54,13 @@ protected object DeserializeBody(string body) { if (Document.ManyData.Count == 0) { - return new HashSet(); + return new HashSet(); } return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); } - if (Document.SingleData == null) + if (Document.SingleData == null) { return null; } @@ -258,20 +258,12 @@ private void SetHasManyRelationship( HasManyAttribute hasManyRelationship, RelationshipEntry relationshipData) { - if (relationshipData.Data != null) - { - // if the relationship is set to null, no need to set the navigation property to null: this is the default value. - var relatedResources = relationshipData.ManyData.Select(rio => - { - AssertHasType(rio, hasManyRelationship); - AssertHasId(rio, hasManyRelationship); - - var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType; - var relatedInstance = ResourceFactory.CreateInstance(relationshipType); - relatedInstance.StringId = rio.Id; - - return relatedInstance; - }).ToHashSet(IdentifiableComparer.Instance); + // If the relationship data is null, there is no need to set the navigation property to null: this is the default value. + if (relationshipData.ManyData != null) + { + var relatedResources = relationshipData.ManyData + .Select(rio => CreateRightResourceForHasMany(hasManyRelationship, rio)) + .ToHashSet(IdentifiableComparer.Instance); var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, hasManyRelationship.Property.PropertyType); hasManyRelationship.SetValue(resource, convertedCollection, ResourceFactory); @@ -280,6 +272,18 @@ private void SetHasManyRelationship( AfterProcessField(resource, hasManyRelationship, relationshipData); } + private IIdentifiable CreateRightResourceForHasMany(HasManyAttribute hasManyRelationship, ResourceIdentifierObject rio) + { + AssertHasType(rio, hasManyRelationship); + AssertHasId(rio, hasManyRelationship); + + var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType; + var relatedInstance = ResourceFactory.CreateInstance(relationshipType); + relatedInstance.StringId = rio.Id; + + return relatedInstance; + } + private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) { if (resourceIdentifierObject.Type == null) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 367093b9a8..0db4f39ab5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -469,8 +469,7 @@ private async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttr } private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( - RelationshipAttribute relationship, - ICollection rightResources) + RelationshipAttribute relationship, ICollection rightResources) { if (rightResources.Any()) { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 0f5cde7f58..6e53d7f160 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -13,8 +13,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; using Xunit; -using Xunit.Abstractions; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -22,7 +22,6 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec public sealed class UpdatingDataTests : IClassFixture> { private readonly IntegrationTestContext _testContext; - private readonly ITestOutputHelper _testOutputHelper; private readonly Faker _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) @@ -33,10 +32,9 @@ public sealed class UpdatingDataTests : IClassFixture p.FirstName, f => f.Name.FirstName()) .RuleFor(p => p.LastName, f => f.Name.LastName()); - public UpdatingDataTests(IntegrationTestContext testContext, ITestOutputHelper testOutputHelper) + public UpdatingDataTests(IntegrationTestContext testContext) { _testContext = testContext; - _testOutputHelper = testOutputHelper; FakeLoggerFactory loggerFactory = null; @@ -517,24 +515,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/people/" + todoItem.Owner.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + //var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseText) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert try { + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + var responseDocument = JsonConvert.DeserializeObject(responseText); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); + responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); + responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); + responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); } - catch(Exception) + catch (Exception exception) { - _testOutputHelper.WriteLine("What can we additionally log here to get insight in why this test is flaking irregularly?"); - throw; + throw new Exception("Flaky test failed with response status " + (int)httpResponse.StatusCode + " and body: <<" + responseText + ">>", exception); } - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); - responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); - responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); - responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 1ef7c962af..663077b73e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -71,7 +71,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_and_apply_sparse_field_set_selection() + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_and_fieldset() { // Arrange var group = WriteFakers.WorkItemGroup.Generate(); @@ -101,8 +101,8 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); responseDocument.SingleData.Id.Should().Be(group.StringId); - responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index da0c680b87..2a9991fe73 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -72,7 +72,7 @@ public void DeserializeResourceIdentifiers_ArrayData_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = (HashSet)_deserializer.Deserialize(body); + var result = (IEnumerable)_deserializer.Deserialize(body); // Assert Assert.Equal("1", result.First().StringId); @@ -85,7 +85,7 @@ public void DeserializeResourceIdentifiers_EmptyArrayData_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = (ISet)_deserializer.Deserialize(body); + var result = (IEnumerable)_deserializer.Deserialize(body); // Assert Assert.Empty(result); From 0a7805dc472facd34816109662960673acbe4215 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 00:10:20 +0200 Subject: [PATCH 118/240] fix cibuild --- .../EntityFrameworkCoreRepository.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index e3f6155980..a56591fe3b 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -255,6 +255,8 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet /// Removes resources from whose ID exists in . /// @@ -265,14 +267,15 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet /// - // private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( - // ISet existingRightResources, ISet resourcesToRemove) - // { - // var newRightResources = new HashSet(existingRightResources, IdentifiableComparer.Instance); - // newRightResources.ExceptWith(resourcesToRemove); - // - // return newRightResources; - // } + private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( + ISet existingRightResources, ISet resourcesToRemove) + { + var newRightResources = new HashSet(existingRightResources, IdentifiableComparer.Instance); + newRightResources.ExceptWith(resourcesToRemove); + + return newRightResources; + } + */ private async Task SaveChangesAsync() { From 844a886867faef8ce04d2637498836f29d481a46 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 01:10:21 +0200 Subject: [PATCH 119/240] added tests for delete resource --- .../Acceptance/Spec/DeletingDataTests.cs | 59 ----- .../Acceptance/Spec/UpdatingDataTests.cs | 2 + .../Acceptance/TodoItemControllerTests.cs | 31 --- .../Writing/Deleting/DeleteResourceTests.cs | 214 ++++++++++++++++++ .../Writing/WriteDbContext.cs | 1 + 5 files changed, 217 insertions(+), 90 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs deleted file mode 100644 index 3e32bebb34..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class DeletingDataTests - { - private readonly AppDbContext _context; - - public DeletingDataTests(TestFixture fixture) - { - _context = fixture.GetRequiredService(); - } - - [Fact] - public async Task Respond_404_If_ResourceDoesNotExist() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var httpMethod = new HttpMethod("DELETE"); - var route = "/api/v1/todoItems/123"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.",errorDocument.Errors[0].Detail); - } - - // TODO: Add test for DeleteRelationshipAsync that only deletes non-existing from the right resources in to-many relationship. - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 6e53d7f160..e4cf9d7632 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -484,6 +484,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // TODO: Add test(s) that save a relationship, then return its data via include. + // TODO: Add test for DeleteRelationshipAsync that only deletes non-existing from the right resources in to-many relationship. + // TODO: This test is flaky. [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index 88242eb988..5efd4d5585 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -371,36 +371,5 @@ public async Task Can_Patch_TodoItemWithNullValue() Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); Assert.Null(deserializedBody.AchievedDate); } - - [Fact] - public async Task Can_Delete_TodoItem() - { - // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(string.Empty)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Empty(body); - - Assert.Null(_context.TodoItems.FirstOrDefault(t => t.Id == todoItem.Id)); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs new file mode 100644 index 0000000000..99641e7f5b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs @@ -0,0 +1,214 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Writing.Deleting +{ + public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + + public DeleteResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_delete_existing_resource() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Where(workItem => workItem.Id == existingWorkItem.Id) + .ToListAsync(); + + workItemsInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Cannot_delete_missing_resource() + { + // Arrange + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingColor = WriteFakers.RgbColor.Generate(); + existingColor.Group = WriteFakers.WorkItemGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + var route = "/rgbColors/" + existingColor.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Where(color => color.Id == existingColor.Id) + .ToListAsync(); + + colorsInDatabase.Should().BeEmpty(); + + var groupsInDatabase = await dbContext.Groups + .Where(group => group.Id == existingColor.Group.Id) + .ToListAsync(); + + groupsInDatabase.Should().HaveCount(1); + groupsInDatabase[0].Color.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_existing_resource_with_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = WriteFakers.WorkItemGroup.Generate(); + existingGroup.Color = WriteFakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItemGroups/" + existingGroup.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("Failed to persist changes in the underlying data store."); + + var stackTrace = JsonConvert.SerializeObject(responseDocument.Errors[0].Meta.Data["stackTrace"], Formatting.Indented); + stackTrace.Should().Contain("violates foreign key constraint"); + } + + [Fact] + public async Task Cannot_delete_existing_resource_with_OneToMany_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("Failed to persist changes in the underlying data store."); + + var stackTrace = JsonConvert.SerializeObject(responseDocument.Errors[0].Meta.Data["stackTrace"], Formatting.Indented); + stackTrace.Should().Contain("violates foreign key constraint"); + } + + [Fact] + public async Task Can_delete_resource_with_ManyToMany_relationship() + { + // Arrange + var existingWorkItemTag = new WorkItemTag + { + Item = WriteFakers.WorkItem.Generate(), + Tag = WriteFakers.WorkTags.Generate() + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItemTags.Add(existingWorkItemTag); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + existingWorkItemTag.Item.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Where(workItem => workItem.Id == existingWorkItemTag.Item.Id) + .ToListAsync(); + + workItemsInDatabase.Should().BeEmpty(); + + var workItemTagsInDatabase = await dbContext.WorkItemTags + .Where(workItemTag => workItemTag.Item.Id == existingWorkItemTag.Item.Id) + .ToListAsync(); + + workItemTagsInDatabase.Should().BeEmpty(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs index 0ff224d8d5..76447853de 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs @@ -6,6 +6,7 @@ public sealed class WriteDbContext : DbContext { public DbSet WorkItems { get; set; } public DbSet WorkTags { get; set; } + public DbSet WorkItemTags { get; set; } public DbSet Groups { get; set; } public DbSet RgbColors { get; set; } public DbSet UserAccounts { get; set; } From b72735a6a7dc565058d27817df932af49b757c4f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 02:19:39 +0200 Subject: [PATCH 120/240] more on tests --- .../Resources/IResourceChangeTracker.cs | 3 + .../Resources/ResourceChangeTracker.cs | 10 ++++ .../Services/JsonApiResourceService.cs | 32 +++++++++++ .../Acceptance/ManyToManyTests.cs | 56 ------------------- ...reateResourceWithClientGeneratedIdTests.cs | 2 +- .../Writing/Deleting/DeleteResourceTests.cs | 4 +- .../Writing/WorkItemGroup.cs | 3 + 7 files changed, 51 insertions(+), 59 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index cceb0994d1..7b0f274925 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -29,5 +29,8 @@ public interface IResourceChangeTracker where TResource : class, I /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. /// bool HasImplicitChanges(); + + // TODO: Remove debugging code for analyzing flaky test. + string DumpContents(); } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 701b677c48..af2bc26725 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -95,5 +95,15 @@ public bool HasImplicitChanges() return false; } + + // TODO: Remove debugging code for analyzing flaky test. + public string DumpContents() + { + var requested = JsonConvert.SerializeObject(_requestedAttributeValues, Formatting.Indented); + var before = JsonConvert.SerializeObject(_initiallyStoredAttributeValues, Formatting.Indented); + var after = JsonConvert.SerializeObject(_finallyStoredAttributeValues, Formatting.Indented); + + return $"Requested:\n{requested}\nBefore:\n{before}\nAfter:\n{after}"; + } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0db4f39ab5..dba2a5b3c5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -13,6 +13,8 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Services @@ -266,6 +268,26 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _traceWriter.LogMethodStart(new {id, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); + + // TODO: Remove debugging code for analyzing flaky test. + bool isFlakyTest = false; + var httpContext = new HttpContextAccessor().HttpContext; + if (httpContext.Request.GetDisplayUrl().StartsWith("http://localhost/api/v1/people/")) + { + var firstNameProperty = resource.GetType().GetProperty("FirstName"); + var lastNameProperty = resource.GetType().GetProperty("LastName"); + + if (firstNameProperty != null && lastNameProperty != null) + { + if ((string) firstNameProperty.GetValue(resource) == "John" && + (string) lastNameProperty.GetValue(resource) == "Doe") + { + isFlakyTest = true; + } + } + } + + var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -299,6 +321,16 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + + + // TODO: Remove debugging code for analyzing flaky test. + if (isFlakyTest && !hasImplicitChanges) + { + string trackerData = _resourceChangeTracker.DumpContents(); + throw new Exception("Detected failing flaky test. Tracker data: " + trackerData); + } + + return hasImplicitChanges ? afterResourceFromDatabase : null; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 57de9b360c..a2adca1f9c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -97,62 +97,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Attributes.Should().BeNull(); } - [Fact] - public async Task Can_Create_Resource_With_HasManyThrough_Relationship() - { - // Arrange - var existingTag = _tagFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Tags.Add(existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "articles", - relationships = new - { - tags = new - { - data = new[] - { - new - { - type = "tags", - id = existingTag.StringId - } - } - } - } - } - }; - - var route = "/api/v1/articles"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - var newArticleId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == newArticleId); - - articleInDatabase.ArticleTags.Should().HaveCount(1); - articleInDatabase.ArticleTags.First().TagId.Should().Be(existingTag.Id); - }); - } - [Fact] public async Task Can_Set_HasManyThrough_Relationship_Through_Primary_Endpoint() { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 663077b73e..17ccd8d8d8 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -71,7 +71,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_and_fieldset() + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_with_fieldset() { // Arrange var group = WriteFakers.WorkItemGroup.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs index 99641e7f5b..6799e7b712 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs @@ -140,7 +140,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_delete_existing_resource_with_OneToMany_relationship() + public async Task Cannot_delete_existing_resource_with_HasMany_relationship() { // Arrange var existingWorkItem = WriteFakers.WorkItem.Generate(); @@ -170,7 +170,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_delete_resource_with_ManyToMany_relationship() + public async Task Can_delete_resource_with_HasManyThrough_relationship() { // Arrange var existingWorkItemTag = new WorkItemTag diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs index 77adfefb94..88170a5420 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs @@ -11,6 +11,9 @@ public sealed class WorkItemGroup : Identifiable [Attr] public string Name { get; set; } + [Attr] + public bool IsPublic { get; set; } + [NotMapped] [Attr] public Guid ConcurrencyToken { get; } = Guid.NewGuid(); From a3a1ffb5ba740656d15db9243851da9a05b7200e Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 23 Oct 2020 09:28:26 +0200 Subject: [PATCH 121/240] chore: review --- .../EntityFrameworkCoreRepository.cs | 22 +++++--- .../Resources/Annotations/HasManyAttribute.cs | 4 +- .../Annotations/HasManyThroughAttribute.cs | 37 ++++++++++++-- .../Building/ResourceObjectBuilder.cs | 8 ++- .../Acceptance/Spec/UpdatingDataTests.cs | 4 +- .../Spec/UpdatingRelationshipsTests.cs | 51 +++++++++++++++++++ 6 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a56591fe3b..83078a7eae 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -365,6 +365,8 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt { // TODO: This is a no-go, because it loads the complete set of related entities, which can be massive. // Instead, it should only load the subset of related entities that is in secondaryResourceIds, and the deduce what still needs to be added. + + // => What you're describing is not possible. We need filtered includes for that, which land in EF Core 5. var primaryResource = await _dbContext.Set() // TODO: Why use AsNoTracking() here? We're not doing that anywhere else. @@ -457,22 +459,26 @@ private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) } } - private static object TryGetValueForProperty(PropertyInfo propertyInfo) + private object TryGetValueForProperty(PropertyInfo propertyInfo) { - if (propertyInfo.PropertyType == typeof(string)) + var propertyType = propertyInfo.PropertyType; + + if (propertyType == typeof(string)) { return string.Empty; } - if (Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null) + if (Nullable.GetUnderlyingType(propertyType) != null) { - // TODO: This looks wrong -- we're returning a System.Type in this case. - // I would expect it needs to return the default value of the underlying type. - return propertyInfo.PropertyType.GetGenericArguments()[0]; + var underlyingType = propertyInfo.PropertyType.GetGenericArguments()[0]; + + return Activator.CreateInstance(underlyingType); } - // TODO: I'm assuming that returning null for a non-nullable value type is okay here. - // If so, then we should throw in case we find a non-string reference type. + if (!propertyType.IsValueType) + { + throw new InvalidOperationException($"Unexpected reference type '{propertyType.Name}' for primary key property '{propertyInfo.Name}'."); + } return null; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index ff4cb17617..9bf62425f7 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -26,8 +26,10 @@ public HasManyAttribute() Links = LinkTypes.All; } - internal virtual IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) + internal virtual IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + return (IEnumerable)base.GetValue(resource); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 3d389bc16c..d1c556983c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -106,18 +106,45 @@ public HasManyThroughAttribute(string throughPropertyName) /// public override object GetValue(object resource) { + + // if (resource == null) throw new ArgumentNullException(nameof(resource)); + // + // // TODO: Passing null for the resourceFactory parameter is wrong here. Instead, GetManyValue() should properly throw when null is passed in. + // return GetManyValue(resource); + + // The resouceFactory argument needs to be an optional param independent of this method calling it. + // In should actually be the responsibility of the relationship attribute to know whether to use the resource factory or not, + // but this is tedious because we're of it being attributes rather than having a meta abstraction. + // We can consider work around it with a static internal setter. + if (resource == null) throw new ArgumentNullException(nameof(resource)); - // TODO: Passing null for the resourceFactory parameter is wrong here. Instead, GetManyValue() should properly throw when null is passed in. - return GetManyValue(resource, null); + IEnumerable throughEntities = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); + + IEnumerable rightResources = throughEntities + .Cast() + .Select(te => RightProperty.GetValue(te)); + + return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); + + } - internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory) + internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) { // TODO: This method contains surprising code: Instead of returning the contents of a collection, // it modifies data and performs logic that is highly specific to what EntityFrameworkCoreRepository needs. + // => The whole point is that we cannot return the contents of the through entity collection. We must first perform a projection. + // The new bit does not add anything new that is specific to EF Core only. Instead, the added bit is only + // specific to JADNC. It is useful because only including Article.ArticleTag rather than Article.ArticleTag.Tag is the equivalent + // of having a primary ID only projection on the secondary resource. + // + // Also, what data modification/logic are you referring to? // This method is not reusable at all, it should not be concerned if resources are loaded, so should be moved into the caller instead. + // => It is actually being reused several times already. // After moving the code, the unneeded copying into new collections multiple times can be removed too. + // => I don't think we can. There is no guarantee that a dev uses the same collection type for the join entities and right resource collections. + if (resource == null) throw new ArgumentNullException(nameof(resource)); @@ -128,8 +155,8 @@ internal override IEnumerable GetManyValue(object resource, IReso // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. var rightResources = rightResourcesAreLoaded - ? throughEntities.Select(entity => RightProperty.GetValue(entity)).Cast() - : throughEntities.Select(entity => CreateRightResourceWithId(entity, resourceFactory)); + ? throughEntities.Select(te => RightProperty.GetValue(te)).Cast() + : throughEntities.Select(te => CreateRightResourceWithId(te, resourceFactory)); return (IEnumerable)TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 071fc73e30..0460cf7a6f 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -91,11 +91,15 @@ private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttrib /// private List GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) { - var relatedResources = (IEnumerable)relationship.GetValue(resource); + var relatedResources = relationship.GetManyValue(resource); var manyData = new List(); if (relatedResources != null) - foreach (IIdentifiable relatedResource in relatedResources) + { + foreach (var relatedResource in relatedResources) + { manyData.Add(GetResourceIdentifier(relatedResource)); + } + } return manyData; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index e4cf9d7632..7edb0a780d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -483,9 +483,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } // TODO: Add test(s) that save a relationship, then return its data via include. - - // TODO: Add test for DeleteRelationshipAsync that only deletes non-existing from the right resources in to-many relationship. - + // TODO: This test is flaky. [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index e88ac77b15..9a6dd8ed82 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -952,6 +952,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_Non_Existing_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint_Without_Failing() + { + // Arrange + var existingPerson = _personFaker.Generate(); + existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = "9998" + }, + new + { + type = "todoItems", + id = "9999" + } + } + }; + + var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var personInDatabase = await dbContext.People + .Include(p => p.TodoItems) + .Where(p => p.Id == existingPerson.Id) + .FirstAsync(); + + personInDatabase.TodoItems.Should().HaveCount(3); + }); + } + [Fact] public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { From 3b36f8f8efeb3800fe04ac54dafef90064bcfa85 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 23 Oct 2020 09:31:59 +0200 Subject: [PATCH 122/240] fix: resource factory null check --- .../Resources/Annotations/HasManyThroughAttribute.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index d1c556983c..ad0d261a8d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -145,14 +145,13 @@ internal override IEnumerable GetManyValue(object resource, IReso // After moving the code, the unneeded copying into new collections multiple times can be removed too. // => I don't think we can. There is no guarantee that a dev uses the same collection type for the join entities and right resource collections. - if (resource == null) throw new ArgumentNullException(nameof(resource)); var value = ThroughProperty.GetValue(resource); var throughEntities = value == null ? Array.Empty() : ((IEnumerable)value).Cast().ToArray(); var rightResourcesAreLoaded = throughEntities.Any() && RightProperty.GetValue(throughEntities.First()) != null; - + // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. var rightResources = rightResourcesAreLoaded ? throughEntities.Select(te => RightProperty.GetValue(te)).Cast() @@ -163,6 +162,8 @@ internal override IEnumerable GetManyValue(object resource, IReso private IIdentifiable CreateRightResourceWithId(object throughEntity, IResourceFactory resourceFactory) { + if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); + var rightResource = resourceFactory.CreateInstance(RightType); rightResource.StringId = RightIdProperty.GetValue(throughEntity)!.ToString(); From 74aa64fb1d8fb8963b7c0fd06d68967111bb5b53 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 23 Oct 2020 10:16:36 +0200 Subject: [PATCH 123/240] chore: review --- .../Annotations/HasManyThroughAttribute.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index ad0d261a8d..afd51f822c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -114,8 +114,8 @@ public override object GetValue(object resource) // The resouceFactory argument needs to be an optional param independent of this method calling it. // In should actually be the responsibility of the relationship attribute to know whether to use the resource factory or not, - // but this is tedious because we're of it being attributes rather than having a meta abstraction. - // We can consider work around it with a static internal setter. + // instead of the caller passing it along. But this is hard because we're working with attributes rather than having a meta abstraction / service + // We can consider working around it with a static internal setter. if (resource == null) throw new ArgumentNullException(nameof(resource)); @@ -134,14 +134,12 @@ internal override IEnumerable GetManyValue(object resource, IReso { // TODO: This method contains surprising code: Instead of returning the contents of a collection, // it modifies data and performs logic that is highly specific to what EntityFrameworkCoreRepository needs. - // => The whole point is that we cannot return the contents of the through entity collection. We must first perform a projection. - // The new bit does not add anything new that is specific to EF Core only. Instead, the added bit is only - // specific to JADNC. It is useful because only including Article.ArticleTag rather than Article.ArticleTag.Tag is the equivalent + // => We cannot around this logic and data modification: we must perform a transformation of this collection before returning it. + // The added bit is only an extension of this. It is not EF Core specific but JADNC specific. + // I think it is relevant because only including Article.ArticleTag rather than Article.ArticleTag.Tag is the equivalent // of having a primary ID only projection on the secondary resource. - // - // Also, what data modification/logic are you referring to? // This method is not reusable at all, it should not be concerned if resources are loaded, so should be moved into the caller instead. - // => It is actually being reused several times already. + // => There are already some cases of it being reused // After moving the code, the unneeded copying into new collections multiple times can be removed too. // => I don't think we can. There is no guarantee that a dev uses the same collection type for the join entities and right resource collections. @@ -154,8 +152,8 @@ internal override IEnumerable GetManyValue(object resource, IReso // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. var rightResources = rightResourcesAreLoaded - ? throughEntities.Select(te => RightProperty.GetValue(te)).Cast() - : throughEntities.Select(te => CreateRightResourceWithId(te, resourceFactory)); + ? throughEntities.Select(e => RightProperty.GetValue(e)).Cast() + : throughEntities.Select(e => CreateRightResourceWithId(e, resourceFactory)); return (IEnumerable)TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } From 515d7780441fd189c9025523c9fcfca7ba369147 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 23 Oct 2020 10:23:52 +0200 Subject: [PATCH 124/240] chore: static setter ResourceFactory HasManyThroughAttribute --- .../Configuration/ApplicationBuilderExtensions.cs | 5 +++++ .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- .../Resources/Annotations/HasManyAttribute.cs | 2 +- .../Annotations/HasManyThroughAttribute.cs | 15 ++++++++------- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 99b671e411..31ecafd905 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -1,5 +1,7 @@ using System; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -42,6 +44,9 @@ public static void UseJsonApi(this IApplicationBuilder builder) }; builder.UseMiddleware(); + + var resourceFactory = builder.ApplicationServices.GetRequiredService(); + HasManyThroughAttribute.ResourceFactory = resourceFactory; } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 83078a7eae..9145548f74 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -238,7 +238,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet GetManyValue(object resource, IResourceFactory resourceFactory = null) + internal virtual IEnumerable GetManyValue(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index afd51f822c..6c9fc4d61f 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -43,6 +43,8 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { + internal static IResourceFactory ResourceFactory { get; set; } + /// /// The name of the join property on the parent resource. /// In the example described above, this would be "ArticleTags". @@ -115,7 +117,8 @@ public override object GetValue(object resource) // The resouceFactory argument needs to be an optional param independent of this method calling it. // In should actually be the responsibility of the relationship attribute to know whether to use the resource factory or not, // instead of the caller passing it along. But this is hard because we're working with attributes rather than having a meta abstraction / service - // We can consider working around it with a static internal setter. + + // We can consider working around it with a static internal setter. I have coded it like this right now as a draft. if (resource == null) throw new ArgumentNullException(nameof(resource)); @@ -130,7 +133,7 @@ public override object GetValue(object resource) } - internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) + internal override IEnumerable GetManyValue(object resource) { // TODO: This method contains surprising code: Instead of returning the contents of a collection, // it modifies data and performs logic that is highly specific to what EntityFrameworkCoreRepository needs. @@ -153,16 +156,14 @@ internal override IEnumerable GetManyValue(object resource, IReso // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. var rightResources = rightResourcesAreLoaded ? throughEntities.Select(e => RightProperty.GetValue(e)).Cast() - : throughEntities.Select(e => CreateRightResourceWithId(e, resourceFactory)); + : throughEntities.Select(CreateRightResourceWithId); return (IEnumerable)TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } - private IIdentifiable CreateRightResourceWithId(object throughEntity, IResourceFactory resourceFactory) + private IIdentifiable CreateRightResourceWithId(object throughEntity) { - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); - - var rightResource = resourceFactory.CreateInstance(RightType); + var rightResource = ResourceFactory.CreateInstance(RightType); rightResource.StringId = RightIdProperty.GetValue(throughEntity)!.ToString(); return rightResource; From f7e233fafedf89e077046b595086228b28729bf9 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 23 Oct 2020 10:31:31 +0200 Subject: [PATCH 125/240] revert --- .../Configuration/ApplicationBuilderExtensions.cs | 5 ----- .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- .../Resources/Annotations/HasManyAttribute.cs | 2 +- .../Annotations/HasManyThroughAttribute.cs | 15 +++++++-------- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 31ecafd905..99b671e411 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -1,7 +1,5 @@ using System; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -44,9 +42,6 @@ public static void UseJsonApi(this IApplicationBuilder builder) }; builder.UseMiddleware(); - - var resourceFactory = builder.ApplicationServices.GetRequiredService(); - HasManyThroughAttribute.ResourceFactory = resourceFactory; } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 9145548f74..83078a7eae 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -238,7 +238,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet GetManyValue(object resource) + internal virtual IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) { if (resource == null) throw new ArgumentNullException(nameof(resource)); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 6c9fc4d61f..afd51f822c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -43,8 +43,6 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { - internal static IResourceFactory ResourceFactory { get; set; } - /// /// The name of the join property on the parent resource. /// In the example described above, this would be "ArticleTags". @@ -117,8 +115,7 @@ public override object GetValue(object resource) // The resouceFactory argument needs to be an optional param independent of this method calling it. // In should actually be the responsibility of the relationship attribute to know whether to use the resource factory or not, // instead of the caller passing it along. But this is hard because we're working with attributes rather than having a meta abstraction / service - - // We can consider working around it with a static internal setter. I have coded it like this right now as a draft. + // We can consider working around it with a static internal setter. if (resource == null) throw new ArgumentNullException(nameof(resource)); @@ -133,7 +130,7 @@ public override object GetValue(object resource) } - internal override IEnumerable GetManyValue(object resource) + internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) { // TODO: This method contains surprising code: Instead of returning the contents of a collection, // it modifies data and performs logic that is highly specific to what EntityFrameworkCoreRepository needs. @@ -156,14 +153,16 @@ internal override IEnumerable GetManyValue(object resource) // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. var rightResources = rightResourcesAreLoaded ? throughEntities.Select(e => RightProperty.GetValue(e)).Cast() - : throughEntities.Select(CreateRightResourceWithId); + : throughEntities.Select(e => CreateRightResourceWithId(e, resourceFactory)); return (IEnumerable)TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } - private IIdentifiable CreateRightResourceWithId(object throughEntity) + private IIdentifiable CreateRightResourceWithId(object throughEntity, IResourceFactory resourceFactory) { - var rightResource = ResourceFactory.CreateInstance(RightType); + if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); + + var rightResource = resourceFactory.CreateInstance(RightType); rightResource.StringId = RightIdProperty.GetValue(throughEntity)!.ToString(); return rightResource; From fcd86e822198866ac684fcb211e03942074c3120 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 23 Oct 2020 10:40:52 +0200 Subject: [PATCH 126/240] fix: Remove AsNoTracking in favour of explicit loading --- .../EntityFrameworkCoreRepository.cs | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 83078a7eae..62cc8fadc9 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -159,13 +159,14 @@ public virtual async Task AddToToManyRelationshipAsync(TId id, ISet What you're describing is not possible. We need filtered includes for that, which land in EF Core 5. - - var primaryResource = await _dbContext.Set() - // TODO: Why use AsNoTracking() here? We're not doing that anywhere else. - .AsNoTracking() - .Where(r => r.Id.Equals(primaryResourceId)) - .Include(hasManyThroughRelationship.ThroughPropertyName) - .FirstAsync(); - + // => What you're describing is not possible because we cannot be sure that ArticleTags is defined as a DbSet on DbContext. + // We would need to load them through filtered includes, for which we need to wait for EF Core 5. + + var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(primaryResourceId)); + + var navigationEntry = GetNavigationEntryForRelationship(hasManyThroughRelationship, primaryResource); + await navigationEntry.LoadAsync(); + var existingRightResources = hasManyThroughRelationship.GetManyValue(primaryResource, _resourceFactory).ToHashSet(IdentifiableComparer.Instance); secondaryResourceIds.ExceptWith(existingRightResources); + + _dbContext.Entry(primaryResource).State = EntityState.Detached; + foreach (var resource in existingRightResources) + { + _dbContext.Entry(resource).State = EntityState.Detached; + } } private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) From 2b9db0d445801a7d731a5c2d46342c1e81298f22 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 15:10:52 +0200 Subject: [PATCH 127/240] Moved tests from the wrong folder --- .../Writing/Creating/CreateResourceTests.cs | 3 +-- .../Creating/CreateResourceWithClientGeneratedIdTests.cs | 3 +-- .../Writing/Creating/CreateResourceWithRelationshipTests.cs | 3 +-- .../Writing/Deleting/DeleteResourceTests.cs | 3 +-- .../{ => IntegrationTests}/Writing/RgbColor.cs | 2 +- .../{ => IntegrationTests}/Writing/RgbColorsController.cs | 2 +- .../{ => IntegrationTests}/Writing/UserAccount.cs | 2 +- .../{ => IntegrationTests}/Writing/UserAccountsController.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkItem.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkItemGroup.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkItemGroupsController.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkItemPriority.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkItemTag.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkItemsController.cs | 2 +- .../{ => IntegrationTests}/Writing/WorkTag.cs | 2 +- .../{ => IntegrationTests}/Writing/WriteDbContext.cs | 2 +- .../{ => IntegrationTests}/Writing/WriteFakers.cs | 2 +- 17 files changed, 17 insertions(+), 21 deletions(-) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/Creating/CreateResourceTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs (98%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/Creating/CreateResourceWithRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/Deleting/DeleteResourceTests.cs (98%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/RgbColor.cs (81%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/RgbColorsController.cs (88%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/UserAccount.cs (85%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/UserAccountsController.cs (88%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkItem.cs (94%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkItemGroup.cs (90%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkItemGroupsController.cs (88%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkItemPriority.cs (58%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkItemTag.cs (76%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkItemsController.cs (87%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WorkTag.cs (80%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WriteDbContext.cs (95%) rename test/JsonApiDotNetCoreExampleTests/{ => IntegrationTests}/Writing/WriteFakers.cs (94%) diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index da404b6cd7..93ed7c8c22 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -6,12 +6,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { public sealed class CreateResourceTests : IClassFixture, WriteDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 17ccd8d8d8..68e047d8b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -6,12 +6,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { public sealed class CreateResourceWithClientGeneratedIdTests : IClassFixture, WriteDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index b03873584b..6e47ac0f9a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -4,12 +4,11 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { public sealed class CreateResourceWithRelationshipTests : IClassFixture, WriteDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 6799e7b712..4ae4a73f97 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -3,12 +3,11 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Xunit; -namespace JsonApiDotNetCoreExampleTests.Writing.Deleting +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting { public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs similarity index 81% rename from test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index 841b8ee0b1..1aeca1faa9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class RgbColor : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs similarity index 88% rename from test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs index 1e62c168dc..45113fe3e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/RgbColorsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class RgbColorsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs similarity index 85% rename from test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs index 7b4ac2d413..1e4b60d612 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/UserAccount.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class UserAccount : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs similarity index 88% rename from test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs index 6057d09576..0e2ff633b7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/UserAccountsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class UserAccountsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs similarity index 94% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs index e3131727de..2e80bae7ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WorkItem : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs similarity index 90% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs index 88170a5420..37dc8cd78c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WorkItemGroup : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs similarity index 88% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs index d41596fcff..fffef616d5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemGroupsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WorkItemGroupsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs similarity index 58% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs index 55bead40ca..31d639dbb7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemPriority.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public enum WorkItemPriority { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs similarity index 76% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs index 553ec7b423..4efad89a0b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemTag.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WorkItemTag { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs similarity index 87% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs index f6bbf52e02..2bfc3c3c42 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkItemsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WorkItemsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs similarity index 80% rename from test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs index b787d5e986..8fe6a903b3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WorkTag.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WorkTag : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs similarity index 95% rename from test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs index 76447853de..147f6d5ac6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { public sealed class WriteDbContext : DbContext { diff --git a/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs similarity index 94% rename from test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs index 0a4d9e5f7d..25389772bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -1,6 +1,6 @@ using Bogus; -namespace JsonApiDotNetCoreExampleTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { internal static class WriteFakers { From 4d1406f4af16d8f2945ba55ef3559817fef132c1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 18:33:55 +0200 Subject: [PATCH 128/240] fix test --- .../Acceptance/Spec/UpdatingRelationshipsTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 9a6dd8ed82..bb74ff8f74 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -953,7 +953,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_Non_Existing_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint_Without_Failing() + public async Task Can_Remove_Non_Existing_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint() { // Arrange var existingPerson = _personFaker.Generate(); @@ -972,12 +972,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "todoItems", - id = "9998" + id = "88888888" }, new { type = "todoItems", - id = "9999" + id = "99999999" } } }; From 9b29fd43f416b9cd87b4c3480a84dcd6a9edecca Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 18:55:58 +0200 Subject: [PATCH 129/240] Tests for posting to HasMany relationship through relationship endpoint --- .../Serialization/BaseDeserializer.cs | 10 +- .../Client/Internal/IRequestSerializer.cs | 4 +- .../Client/Internal/RequestSerializer.cs | 2 +- .../Serialization/JsonApiReader.cs | 6 +- .../Acceptance/ManyToManyTests.cs | 112 --- .../ResourceDefinitionTests.cs | 4 +- .../Spec/FunctionalTestCollection.cs | 5 +- .../Acceptance/Spec/UpdatingDataTests.cs | 4 +- .../Spec/UpdatingRelationshipsTests.cs | 239 ------ .../Acceptance/TestFixture.cs | 3 +- .../Writing/Creating/CreateResourceTests.cs | 9 +- ...reateResourceWithClientGeneratedIdTests.cs | 3 +- .../CreateResourceWithRelationshipTests.cs | 17 +- .../Writing/Deleting/DeleteResourceTests.cs | 3 +- .../AddToToManyRelationshipTests.cs | 787 ++++++++++++++++++ test/MultiDbContextTests/ResourceTests.cs | 2 +- test/NoEntityFrameworkTests/WorkItemTests.cs | 2 +- 17 files changed, 825 insertions(+), 387 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 98127132b8..8334a51d07 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -157,10 +157,8 @@ private IIdentifiable ParseResourceObject(ResourceObject data) var resourceContext = ResourceContextProvider.GetResourceContext(data.Type); if (resourceContext == null) { - throw new InvalidRequestBodyException("Payload includes unknown resource type.", - $"The resource type '{data.Type}' is not registered on the resource graph. " + - "If you are using Entity Framework Core, make sure the DbSet matches the expected resource name. " + - "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); + throw new InvalidRequestBodyException("Request body includes unknown resource type.", + $"Resource of type '{data.Type}' does not exist.", null); } var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); @@ -292,7 +290,7 @@ private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, Re ? $"Expected 'type' element in relationship '{relationship.PublicName}'." : "Expected 'type' element in 'data' element."; - throw new InvalidRequestBodyException("Payload must include 'type' element.", details, null); + throw new InvalidRequestBodyException("Request body must include 'type' element.", details, null); } } @@ -300,7 +298,7 @@ private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, Rela { if (resourceIdentifierObject.Id == null) { - throw new InvalidRequestBodyException("Payload must include 'id' element.", + throw new InvalidRequestBodyException("Request body must include 'id' element.", $"Expected 'id' element in relationship '{relationship.PublicName}'.", null); } } diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs index eabca7e593..c088bdeeee 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs @@ -24,14 +24,14 @@ public interface IRequestSerializer string Serialize(IReadOnlyCollection resources); /// - /// Sets the attributes that will be included in the serialized payload. + /// Sets the attributes that will be included in the serialized request body. /// You can use /// to conveniently access the desired instances. /// public IReadOnlyCollection AttributesToSerialize { set; } /// - /// Sets the relationships that will be included in the serialized payload. + /// Sets the relationships that will be included in the serialized request body. /// You can use /// to conveniently access the desired instances. /// diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs index 6c88aaf723..b01ab6f11a 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs @@ -82,7 +82,7 @@ private IReadOnlyCollection GetAttributesToSerialize(IIdentifiabl var currentResourceType = resource.GetType(); if (_currentTargetedResource != currentResourceType) // We're dealing with a relationship that is being serialized, for which - // we never want to include any attributes in the payload. + // we never want to include any attributes in the request body. return new List(); if (AttributesToSerialize == null) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index b1c0c9becf..7e6f2234b4 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -109,7 +109,7 @@ private void ValidatePatchRequestIncludesId(InputFormatterContext context, objec bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); if (hasMissingId) { - throw new InvalidRequestBodyException("Payload must include 'id' element.", null, body); + throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); } if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) @@ -119,13 +119,13 @@ private void ValidatePatchRequestIncludesId(InputFormatterContext context, objec } } - /// Checks if the deserialized payload has an ID included + /// Checks if the deserialized request body has an ID included private bool HasMissingId(object model) { return TryGetId(model, out string id) && string.IsNullOrEmpty(id); } - /// Checks if all elements in the deserialized payload have an ID included + /// Checks if all elements in the deserialized request body have an ID included private bool HasMissingId(IEnumerable models) { foreach (var model in models) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index a2adca1f9c..37075910f8 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -278,117 +277,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_Add_To_HasManyThrough_Relationship_Through_Relationships_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - var existingTag = _tagFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingArticleTag, existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "tags", - id = existingTag.StringId - } - } - }; - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - - articleInDatabase.ArticleTags.Should().HaveCount(2); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticleTag.Tag.Id); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); - }); - } - - [Fact] - public async Task Can_Add_Already_Related_Resource_Without_It_Being_Readded_To_HasManyThrough_Relationship_Through_Relationships_Endpoint() - { - // Arrange - var existingArticle = _articleFaker.Generate(); - existingArticle.ArticleTags = new HashSet - { - new ArticleTag {Tag = _tagFaker.Generate()}, - new ArticleTag {Tag = _tagFaker.Generate()} - }; - - var existingTag = _tagFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingArticle, existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "tags", - id = existingArticle.ArticleTags.ElementAt(1).Tag.StringId - }, - new - { - type = "tags", - id = existingTag.StringId - } - } - }; - - var route = $"/api/v1/articles/{existingArticle.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticle.Id); - - articleInDatabase.ArticleTags.Should().HaveCount(3); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticle.ArticleTags.ElementAt(0).Tag.Id); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticle.ArticleTags.ElementAt(1).Tag.Id); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); - }); - } - [Fact] public async Task Can_Delete_From_HasManyThrough_Relationship_Through_Relationships_Endpoint() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index d07e700fd3..d3929c6a6c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -213,7 +213,7 @@ public async Task Article_Is_Hidden() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); Assert.DoesNotContain(toBeExcluded, body); } @@ -254,7 +254,7 @@ public async Task Tag_Is_Hidden() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); Assert.DoesNotContain(toBeExcluded, body); } ///// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs index 7e4a14b094..1030ff426c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs @@ -87,9 +87,8 @@ protected IResponseDeserializer GetDeserializer() protected void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) { - var content = response.Content.ReadAsStringAsync(); - content.Wait(); - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {content.Result}"); + var responseBody = response.Content.ReadAsStringAsync().Result; + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } protected void ClearDbContext() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 7edb0a780d..5d10537bd5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -227,7 +227,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'id' element."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); } @@ -273,7 +273,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Respond_422_If_Broken_JSON_Payload() + public async Task Respond_422_If_Broken_JSON_Request_Body() { // Arrange var requestBody = "{ \"data\" {"; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index bb74ff8f74..83791f22de 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -700,155 +700,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_Add_To_ToMany_Relationship_Through_Relationship_Endpoint() - { - // Arrange - var person = _personFaker.Generate(); - person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - var otherTodoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(person, otherTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = otherTodoItem.StringId - } - } - }; - - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) - .FirstAsync(); - - personInDatabase.TodoItems.Should().HaveCount(4); - personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == otherTodoItem.Id); - }); - } - - [Fact] - public async Task Can_Add_Already_Related_Resource_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_It_Being_Readded() - { - // Arrange - var person = _personFaker.Generate(); - person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = person.TodoItems.ElementAt(0).StringId - }, - } - }; - - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) - .FirstAsync(); - - personInDatabase.TodoItems.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_Add_Duplicate_Resources_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_Them_Being_Added_More_Than_Once() - { - // Arrange - var existingPerson = _personFaker.Generate(); - existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - var existingTodoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPerson, existingTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = existingTodoItem.StringId - }, - new - { - type = "todoItems", - id = existingTodoItem.StringId - } - } - }; - - var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .Where(p => p.Id == existingPerson.Id) - .FirstAsync(); - - personInDatabase.TodoItems.Should().HaveCount(4); - personInDatabase.TodoItems.Should().ContainSingle(item => item.Id == existingTodoItem.Id); - }); - } - [Fact] public async Task Can_Delete_From_ToMany_Relationship_Through_Relationship_Endpoint() { @@ -1115,96 +966,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' with ID '99999999' does not exist."); } - [Fact] - public async Task Fails_When_Patching_On_Relationships_Endpoint_With_Unknown_Relationship() - { - // Arrange - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(person, todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person.StringId - } - }; - - var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/doesNotExist"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'doesNotExist'."); - } - - [Fact] - public async Task Fails_When_Posting_To_Many_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resources() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(todoItem, person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "people", - id = person.StringId - }, - new - { - type = "people", - id = "9999000" - }, - new - { - type = "people", - id = "9999111" - } - } - }; - - var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/stakeHolders"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '9999000' being assigned to relationship 'stakeHolders' does not exist."); - - responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[1].Detail.Should().Be("Resource of type 'people' with ID '9999111' being assigned to relationship 'stakeHolders' does not exist."); - } - [Fact] public async Task Fails_When_Patching_To_One_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resource() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index 5868e34fc0..8054663ef3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -86,7 +86,8 @@ public void ReloadDbContext() public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) { - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); + var responseBody = response.Content.ReadAsStringAsync().Result; + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } private bool disposedValue; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 93ed7c8c22..5050e7faf5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -12,7 +12,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { - public sealed class CreateResourceTests : IClassFixture, WriteDbContext>> + public sealed class CreateResourceTests + : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; @@ -350,7 +351,7 @@ public async Task Cannot_create_resource_for_missing_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } @@ -379,8 +380,8 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("The resource type 'doesNotExist' is not registered on the resource graph."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist."); responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 68e047d8b0..2bf447c809 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -12,7 +12,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { - public sealed class CreateResourceWithClientGeneratedIdTests : IClassFixture, WriteDbContext>> + public sealed class CreateResourceWithClientGeneratedIdTests + : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 6e47ac0f9a..1a2371e505 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -10,7 +10,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { - public sealed class CreateResourceWithRelationshipTests : IClassFixture, WriteDbContext>> + public sealed class CreateResourceWithRelationshipTests + : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; @@ -313,7 +314,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in relationship 'assignedTo'. - Request body: <<"); } @@ -349,7 +350,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'id' element."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in relationship 'assignedTo'. - Request body: <<"); } @@ -452,8 +453,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); newWorkItemInDatabase.Subscribers.Should().HaveCount(2); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(x => x.Id == existingUserAccounts[0].Id); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(x => x.Id == existingUserAccounts[1].Id); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); + newWorkItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); }); } @@ -778,7 +779,7 @@ public async Task Can_create_resource_with_unknown_relationship() { data = new { - type = "doesNotExists", + type = "doesNotExist", id = "12345678" } } @@ -844,7 +845,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'type' element."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in relationship 'subscribers'. - Request body: <<"); } @@ -883,7 +884,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'id' element."); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in relationship 'subscribers'. - Request body: <<"); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 4ae4a73f97..b631e62e0a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -9,7 +9,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting { - public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> + public sealed class DeleteResourceTests + : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..a8c9dfad6b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,787 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class AddToToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + + public AddToToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_add_to_HasOne_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingUserAccount = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Only to-many relationships can be updated through this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignedTo' must be a to-many relationship."); + } + + [Fact] + public async Task Can_add_to_HasMany_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_add_to_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + } + }; + + var existingTags = WriteFakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem); + dbContext.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + + [Fact] + public async Task Cannot_add_for_missing_type() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_add_for_unknown_type() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_add_for_missing_ID() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_add_for_unknown_ID() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_add_for_unknown_IDs() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_add_to_unknown_resource_ID_in_url() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = "/workItems/99999999/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_add_for_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in POST request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + } + + [Fact] + public async Task Can_add_already_attached_resource() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); + }); + } + + [Fact] + public async Task Can_add_with_already_attached_HasMany_resources() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_add_with_already_attached_HasManyThrough_resources() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + } + }; + + var existingTag = WriteFakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.WorkItemTags.Should().HaveCount(2); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTag.Id); + }); + } + + [Fact] + public async Task Can_add_with_duplicates() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_add_with_empty_list() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); + }); + } + } +} diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 403f51c2e7..4dffd3ee27 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -66,7 +66,7 @@ private static void AssertStatusCode(HttpStatusCode expected, HttpResponseMessag if (expected != response.StatusCode) { var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(false, $"Got {response.StatusCode} status code instead of {expected}. Payload: {responseBody}"); + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } } } diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index 5b5a39dd28..f118483b76 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -160,7 +160,7 @@ private static void AssertStatusCode(HttpStatusCode expected, HttpResponseMessag if (expected != response.StatusCode) { var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(false, $"Got {response.StatusCode} status code instead of {expected}. Payload: {responseBody}"); + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } } } From d8096cfc5d8d92efd7fab5cab7ad928e35e44dce Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 19:17:55 +0200 Subject: [PATCH 130/240] fix flaky test --- .../Resources/IResourceChangeTracker.cs | 3 -- .../Resources/ResourceChangeTracker.cs | 10 ------ .../Services/JsonApiResourceService.cs | 30 ----------------- .../Acceptance/Spec/UpdatingDataTests.cs | 33 +++++++------------ 4 files changed, 11 insertions(+), 65 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index 7b0f274925..cceb0994d1 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -29,8 +29,5 @@ public interface IResourceChangeTracker where TResource : class, I /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. /// bool HasImplicitChanges(); - - // TODO: Remove debugging code for analyzing flaky test. - string DumpContents(); } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index af2bc26725..701b677c48 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -95,15 +95,5 @@ public bool HasImplicitChanges() return false; } - - // TODO: Remove debugging code for analyzing flaky test. - public string DumpContents() - { - var requested = JsonConvert.SerializeObject(_requestedAttributeValues, Formatting.Indented); - var before = JsonConvert.SerializeObject(_initiallyStoredAttributeValues, Formatting.Indented); - var after = JsonConvert.SerializeObject(_finallyStoredAttributeValues, Formatting.Indented); - - return $"Requested:\n{requested}\nBefore:\n{before}\nAfter:\n{after}"; - } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index dba2a5b3c5..5d7d4b85dc 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -268,26 +268,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _traceWriter.LogMethodStart(new {id, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - - // TODO: Remove debugging code for analyzing flaky test. - bool isFlakyTest = false; - var httpContext = new HttpContextAccessor().HttpContext; - if (httpContext.Request.GetDisplayUrl().StartsWith("http://localhost/api/v1/people/")) - { - var firstNameProperty = resource.GetType().GetProperty("FirstName"); - var lastNameProperty = resource.GetType().GetProperty("LastName"); - - if (firstNameProperty != null && lastNameProperty != null) - { - if ((string) firstNameProperty.GetValue(resource) == "John" && - (string) lastNameProperty.GetValue(resource) == "Doe") - { - isFlakyTest = true; - } - } - } - - var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -321,16 +301,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - - - // TODO: Remove debugging code for analyzing flaky test. - if (isFlakyTest && !hasImplicitChanges) - { - string trackerData = _resourceChangeTracker.DumpContents(); - throw new Exception("Detected failing flaky test. Tracker data: " + trackerData); - } - - return hasImplicitChanges ? afterResourceFromDatabase : null; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 5d10537bd5..604e2e074f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -483,8 +483,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } // TODO: Add test(s) that save a relationship, then return its data via include. - - // TODO: This test is flaky. + [Fact] public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() { @@ -506,7 +505,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = todoItem.Owner.StringId, attributes = new Dictionary { - ["firstName"] = "John", + ["firstName"] = "#John", ["lastName"] = "Doe" } } @@ -515,26 +514,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/people/" + todoItem.Owner.StringId; // Act - //var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - var (httpResponse, responseText) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - try - { - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - var responseDocument = JsonConvert.DeserializeObject(responseText); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); - responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); - responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); - responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); - } - catch (Exception exception) - { - throw new Exception("Flaky test failed with response status " + (int)httpResponse.StatusCode + " and body: <<" + responseText + ">>", exception); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["firstName"].Should().Be("#John"); + responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); + responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); + responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); } [Fact] From 4bb8dd923c39e2566d9b89de9c2300daba0850e4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 23 Oct 2020 19:59:20 +0200 Subject: [PATCH 131/240] Deterministic fakers --- .../Acceptance/Spec/UpdatingDataTests.cs | 4 +- ...reateResourceWithClientGeneratedIdTests.cs | 10 +-- .../CreateResourceWithRelationshipTests.cs | 12 ++- .../IntegrationTests/Writing/WriteFakers.cs | 81 +++++++++++++++++-- 4 files changed, 88 insertions(+), 19 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 604e2e074f..f46f4fac1e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -505,7 +505,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = todoItem.Owner.StringId, attributes = new Dictionary { - ["firstName"] = "#John", + ["firstName"] = "John", ["lastName"] = "Doe" } } @@ -520,7 +520,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["firstName"].Should().Be("#John"); + responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 2bf447c809..cfac920138 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -173,6 +173,9 @@ public async Task Cannot_create_resource_for_existing_client_generated_ID() var existingColor = WriteFakers.RgbColor.Generate(); existingColor.Id = "#FFFFFF"; + var colorToCreate = WriteFakers.RgbColor.Generate(); + colorToCreate.Id = existingColor.Id; + await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); @@ -181,18 +184,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var color = WriteFakers.RgbColor.Generate(); - color.Id = existingColor.Id; - var requestBody = new { data = new { type = "rgbColors", - id = color.StringId, + id = colorToCreate.StringId, attributes = new { - displayName = color.DisplayName + displayName = colorToCreate.DisplayName } } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 1a2371e505..dc767eb1a6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -212,6 +212,7 @@ public async Task Can_create_resource_with_HasOne_relationship_with_include_and_ { // Arrange var existingUserAccount = WriteFakers.UserAccount.Generate(); + var workItem = WriteFakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -219,8 +220,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var workItem = WriteFakers.WorkItem.Generate(); - var requestBody = new { data = new @@ -678,6 +677,7 @@ public async Task Can_create_resource_with_HasManyThrough_relationship_with_incl { // Arrange var existingTags = WriteFakers.WorkTags.Generate(3); + var workItemToCreate = WriteFakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -685,8 +685,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var workItem = WriteFakers.WorkItem.Generate(); - var requestBody = new { data = new @@ -694,8 +692,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "workItems", attributes = new { - description = workItem.Description, - priority = workItem.Priority + description = workItemToCreate.Description, + priority = workItemToCreate.Priority }, relationships = new { @@ -734,7 +732,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItemToCreate.Priority.ToString("G")); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs index 25389772bf..39038b4932 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -1,27 +1,98 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; using Bogus; +using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { internal static class WriteFakers { - public static Faker WorkItem { get; } = new Faker() + public static Faker WorkItem => new Faker() + .UseSeed(GetFakerSeed()) .RuleFor(p => p.Description, f => f.Lorem.Sentence()) .RuleFor(p => p.DueAt, f => f.Date.Future()) .RuleFor(p => p.Priority, f => f.PickRandom()); - public static Faker WorkTags { get; } = new Faker() + public static Faker WorkTags => new Faker() + .UseSeed(GetFakerSeed()) .RuleFor(p => p.Text, f => f.Lorem.Word()) .RuleFor(p => p.IsBuiltIn, f => f.Random.Bool()); - public static Faker UserAccount { get; } = new Faker() + public static Faker UserAccount => new Faker() + .UseSeed(GetFakerSeed()) .RuleFor(p => p.FirstName, f => f.Name.FirstName()) .RuleFor(p => p.LastName, f => f.Name.LastName()); - public static Faker WorkItemGroup { get; } = new Faker() + public static Faker WorkItemGroup => new Faker() + .UseSeed(GetFakerSeed()) .RuleFor(p => p.Name, f => f.Lorem.Word()); - public static Faker RgbColor { get; } = new Faker() + public static Faker RgbColor => new Faker() + .UseSeed(GetFakerSeed()) .RuleFor(p=>p.Id, f=>f.Random.Hexadecimal(6)) .RuleFor(p => p.DisplayName, f => f.Lorem.Word()); + + private static int GetFakerSeed() + { + // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. + MethodBase testMethod = GetTestMethod(); + var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; + + return GetDeterministicHashCode(testName); + } + + private static MethodBase GetTestMethod() + { + var stackTrace = new StackTrace(); + + var testMethod = stackTrace.GetFrames() + .Select(stackFrame => stackFrame?.GetMethod()) + .FirstOrDefault(IsTestMethod); + + if (testMethod == null) + { + // If called after the first await statement, the test method is no longer on the stack, + // but has been replaced with the compiler-generated async/wait state machine. + throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method."); + } + + return testMethod; + } + + private static bool IsTestMethod(MethodBase method) + { + if (method == null) + { + return false; + } + + return method.GetCustomAttribute(typeof(FactAttribute)) != null || method.GetCustomAttribute(typeof(TheoryAttribute)) != null; + } + + private static int GetDeterministicHashCode(string source) + { + // https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ + unchecked + { + int hash1 = (5381 << 16) + 5381; + int hash2 = hash1; + + for (int i = 0; i < source.Length; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ source[i]; + + if (i == source.Length - 1) + { + break; + } + + hash2 = ((hash2 << 5) + hash2) ^ source[i + 1]; + } + + return hash1 + hash2 * 1566083941; + } + } } } From 272a102ed7ddeee6ca178bda444734aad6d9b00f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 24 Oct 2020 13:16:14 +0200 Subject: [PATCH 132/240] Updates on TODOs --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 2 -- .../Resources/Annotations/HasOneAttribute.cs | 2 ++ src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 154838f8cd..f69682d071 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -216,8 +216,6 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } var updated = await _update.UpdateAsync(id, resource); - - // TODO: json:api spec says to return 204 without body when no side-effects. See other comments on how this could be interpreted for relationships too. return updated == null ? (IActionResult) NoContent() : Ok(updated); } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index d0e739aef9..969fd48661 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -42,6 +42,8 @@ public override void SetValue(object resource, object newValue, IResourceFactory if (resource == null) throw new ArgumentNullException(nameof(resource)); if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); + // TODO: Given recent changes, does the following code still need access to foreign keys, or can this be handled by the caller now? + // If we're deleting the relationship (setting it to null), we set the foreignKey to null. // We could also set the actual property to null, but then we would first need to load the // current relationship, which requires an extra query. diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 8334a51d07..407d42fd64 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -220,7 +220,10 @@ private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasO || foreignKey.PropertyType == typeof(string); if (id == null && !foreignKeyPropertyIsNullableType) { - // this happens when a non-optional relationship is deliberately set to null. + // TODO: FormatException does not look like the right exception type here. + // I would expect such constraints to be checked in the ResourceService layer instead. + + // This happens when a non-optional relationship is deliberately set to null. // For a server deserializer, it should be mapped to a BadRequest HTTP error code. throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type."); } From 96562ebd2918f79181cb3b7aef8874091eda79d4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 24 Oct 2020 14:05:02 +0200 Subject: [PATCH 133/240] reduced test duplication --- .../CreateResourceWithRelationshipTests.cs | 14 ++++++- .../AddToToManyRelationshipTests.cs | 38 ------------------- 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index dc767eb1a6..bca382ef86 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -887,7 +887,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() } [Fact] - public async Task Cannot_create_resource_for_unknown_HasMany_relationship_ID() + public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() { // Arrange var requestBody = new @@ -905,6 +905,11 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_ID() { type = "workItems", id = "12345678" + }, + new + { + type = "workItems", + id = "87654321" } } } @@ -920,10 +925,15 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'workItems' with ID '12345678' being assigned to relationship 'assignedItems' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().StartWith("Resource of type 'workItems' with ID '87654321' being assigned to relationship 'assignedItems' does not exist."); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index a8c9dfad6b..aa7ec3caff 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -284,44 +284,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); } - [Fact] - public async Task Cannot_add_for_unknown_ID() - { - // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(existingWorkItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = 99999999 - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); - } - [Fact] public async Task Cannot_add_for_unknown_IDs() { From f61b44f3ee5ba2d56afd1d47f599a89634a42ca6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 24 Oct 2020 17:21:09 +0200 Subject: [PATCH 134/240] Added tests for removing from to-many relationships through DELETE relationship endpoint --- .../EntityFrameworkCoreRepository.cs | 1 - .../Building/ResourceObjectBuilder.cs | 1 - .../Services/JsonApiResourceService.cs | 2 - .../Acceptance/Spec/UpdatingDataTests.cs | 2 - .../Spec/UpdatingRelationshipsTests.cs | 154 ---- .../Acceptance/TodoItemControllerTests.cs | 1 - .../CreateResourceWithRelationshipTests.cs | 4 +- .../AddToToManyRelationshipTests.cs | 18 +- .../RemoveFromToManyRelationshipTests.cs | 760 ++++++++++++++++++ 9 files changed, 770 insertions(+), 173 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 62cc8fadc9..031373bf3f 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -13,7 +13,6 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 0460cf7a6f..56ae8e389e 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 5d7d4b85dc..0db4f39ab5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -13,8 +13,6 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Services diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index f46f4fac1e..cba40818a2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; @@ -13,7 +12,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 83791f22de..4ef433459a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -700,160 +700,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_Delete_From_ToMany_Relationship_Through_Relationship_Endpoint() - { - // Arrange - var existingPerson = _personFaker.Generate(); - existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(existingPerson); - await dbContext.SaveChangesAsync(); - }); - - var todoItemToDelete = existingPerson.TodoItems.ElementAt(0); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = todoItemToDelete.StringId - } - } - }; - - var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .Where(p => p.Id == existingPerson.Id) - .FirstAsync(); - - personInDatabase.TodoItems.Should().HaveCount(2); - personInDatabase.TodoItems.Should().NotContain(item => item.Id == todoItemToDelete.Id); - }); - } - - [Fact] - public async Task Can_Delete_Unrelated_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint_Without_Failing() - { - // Arrange - var existingPerson = _personFaker.Generate(); - existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - var unrelatedTodoItems = _todoItemFaker.Generate(2).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPerson); - dbContext.AddRange(unrelatedTodoItems); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = unrelatedTodoItems.ElementAt(0).StringId - }, - new - { - type = "todoItems", - id = unrelatedTodoItems.ElementAt(1).StringId - } - } - }; - - var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .Where(p => p.Id == existingPerson.Id) - .FirstAsync(); - - personInDatabase.TodoItems.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_Remove_Non_Existing_Resources_From_ToMany_Relationship_Through_Relationship_Endpoint() - { - // Arrange - var existingPerson = _personFaker.Generate(); - existingPerson.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPerson); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = "88888888" - }, - new - { - type = "todoItems", - id = "99999999" - } - } - }; - - var route = $"/api/v1/people/{existingPerson.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .Where(p => p.Id == existingPerson.Id) - .FirstAsync(); - - personInDatabase.TodoItems.Should().HaveCount(3); - }); - } - [Fact] public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index 5efd4d5585..637c41e77d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index bca382ef86..a44e3f13f8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -1018,9 +1018,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccounts[0].Id); newWorkItemInDatabase.Subscribers.Should().HaveCount(1); - newWorkItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingUserAccounts[1].Id); + newWorkItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); newWorkItemInDatabase.WorkItemTags.Should().HaveCount(1); - newWorkItemInDatabase.WorkItemTags.ElementAt(0).Tag.Id.Should().Be(existingTag.Id); + newWorkItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index aa7ec3caff..3849aa98cb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -125,8 +125,8 @@ public async Task Can_add_to_HasManyThrough_relationship() await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingWorkItem); - dbContext.AddRange(existingTags); + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); await dbContext.SaveChangesAsync(); }); @@ -377,7 +377,7 @@ public async Task Cannot_add_to_unknown_resource_ID_in_url() await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.WorkItems.Add(existingWorkItem); + dbContext.AddRange(existingWorkItem, existingSubscriber); await dbContext.SaveChangesAsync(); }); @@ -527,12 +527,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(); workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); }); } [Fact] - public async Task Can_add_with_already_attached_HasMany_resources() + public async Task Can_add_to_HasMany_relationship_with_already_attached_resources() { // Arrange var existingWorkItem = WriteFakers.WorkItem.Generate(); @@ -593,7 +593,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_with_already_attached_HasManyThrough_resources() + public async Task Can_add_to_HasManyThrough_relationship_with_already_attached_resource() { // Arrange var existingWorkItem = WriteFakers.WorkItem.Generate(); @@ -702,7 +702,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(); workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingSubscriber.Id); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); }); } @@ -711,7 +711,6 @@ public async Task Can_add_with_empty_list() { // Arrange var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -741,8 +740,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Where(workItem => workItem.Id == existingWorkItem.Id) .FirstAsync(); - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.ElementAt(0).Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().HaveCount(0); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..e417e2a914 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,760 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class RemoveFromToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + + public RemoveFromToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_remove_from_HasOne_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.AssignedTo = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.AssignedTo.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Only to-many relationships can be updated through this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignedTo' must be a to-many relationship."); + } + + [Fact] + public async Task Can_remove_from_HasMany_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + userAccountsInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_remove_from_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(2).Tag.Id); + + var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); + tagsInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Cannot_remove_for_missing_type() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_type() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_remove_for_missing_ID() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_remove_unknown_IDs_from_HasMany_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being removed from a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being removed from relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being removed from a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being removed from relationship 'subscribers' does not exist."); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_remove_unknown_IDs_from_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being removed from a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workTags' with ID '88888888' being removed from relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being removed from a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being removed from relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_remove_from_unknown_resource_ID_in_url() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = "/workItems/99999999/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_remove_for_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in DELETE request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + } + + [Fact] + public async Task Can_remove_from_HasMany_relationship_with_unrelated_existing_resource() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + var existingSubscriber = WriteFakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + userAccountsInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_remove_from_HasManyThrough_relationship_with_unrelated_existing_resource() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = WriteFakers.WorkTags.Generate() + } + }; + var existingTag = WriteFakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingWorkItem, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId + }, + new + { + type = "workTags", + id = existingTag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + + var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); + tagsInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_remove_with_duplicates() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_remove_with_empty_list() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstAsync(); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); + }); + } + } +} From 7cd3ff034c1442867fc90738212ab34ebdffd0fe Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 24 Oct 2020 17:42:48 +0200 Subject: [PATCH 135/240] tomany test tweaks --- .../AddToToManyRelationshipTests.cs | 97 ++++++++++--------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 3849aa98cb..b0a6103d96 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -285,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_add_for_unknown_IDs() + public async Task Cannot_add_unknown_IDs_to_HasMany_relationship() { // Arrange var existingWorkItem = WriteFakers.WorkItem.Generate(); @@ -332,6 +332,54 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); } + [Fact] + public async Task Cannot_add_unknown_IDs_to_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = WriteFakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workTags' with ID '88888888' being assigned to relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); + } + [Fact] public async Task Cannot_add_to_unknown_resource_type_in_url() { @@ -484,53 +532,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in POST request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); } - [Fact] - public async Task Can_add_already_attached_resource() - { - // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(existingWorkItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); - - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); - }); - } - [Fact] public async Task Can_add_to_HasMany_relationship_with_already_attached_resources() { From 95f0e354f60adc9df3603e952e1d6c2b4a6ce498 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 24 Oct 2020 18:12:49 +0200 Subject: [PATCH 136/240] removed old test --- .../Acceptance/ManyToManyTests.cs | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 37075910f8..2f18cb1486 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -276,53 +276,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => articleInDatabase.ArticleTags.Single().TagId.Should().Be(existingTag.Id); }); } - - [Fact] - public async Task Can_Delete_From_HasManyThrough_Relationship_Through_Relationships_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ArticleTags.AddRange(existingArticleTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "tags", - id = existingArticleTag.Tag.StringId - } - } - }; - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - - articleInDatabase.ArticleTags.Should().BeEmpty(); - }); - } } } From 04db10ec9825a514468b6920081fd6528b60517f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 24 Oct 2020 18:36:12 +0200 Subject: [PATCH 137/240] more on tests --- src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs | 2 +- src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs | 2 +- .../Hooks/Internal/Execution/ResourceHook.cs | 6 +++--- .../Hooks/Internal/ResourceHookExecutor.cs | 2 +- .../Acceptance/Spec/ResourceTypeMismatchTests.cs | 2 +- .../Acceptance/Spec/UpdatingDataTests.cs | 2 +- .../IntegrationTests/Meta/ResourceMetaTests.cs | 2 +- test/UnitTests/Controllers/CoreJsonApiControllerTests.cs | 4 ++-- test/UnitTests/Internal/TypeHelper_Tests.cs | 2 +- .../Serialization/Client/ResponseDeserializerTests.cs | 6 +++--- test/UnitTests/Serialization/Common/DocumentParserTests.cs | 4 ++-- .../Serialization/Common/ResourceObjectBuilderTests.cs | 2 +- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index bdbc86cda5..cd493d1c5e 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -107,7 +107,7 @@ public ResourceGraphBuilder Add(Type resourceType, Type idType = null, string pu IdentityType = idType, Attributes = GetAttributes(resourceType), Relationships = GetRelationships(resourceType), - EagerLoads = GetEagerLoads(resourceType), + EagerLoads = GetEagerLoads(resourceType) }; private IReadOnlyCollection GetAttributes(Type resourceType) diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index 1c91f34e9e..fb607fc56d 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -17,7 +17,7 @@ public InvalidRequestBodyException(string reason, string details, string request { Title = reason != null ? "Failed to deserialize request body: " + reason - : "Failed to deserialize request body.", + : "Failed to deserialize request body." }, innerException) { _details = details; diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs index f60978586f..310bdb808c 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Hooks.Internal.Execution +namespace JsonApiDotNetCore.Hooks.Internal.Execution { /// @@ -18,7 +18,7 @@ public enum ResourceHook AfterRead, AfterUpdate, AfterDelete, - AfterUpdateRelationship, + AfterUpdateRelationship } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index f2b77c10fa..360b469093 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -353,7 +353,7 @@ private void FireForAffectedImplicits(Type resourceTypeToInclude, Dictionary _resourceGraph.GetInverseRelationship(kvp.Key), kvp => kvp.Value); var resourcesByRelationship = CreateRelationshipHelper(resourceTypeToInclude, inverse); - CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, new object[] { resourcesByRelationship, pipeline, }); + CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, new object[] { resourcesByRelationship, pipeline}); } /// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs index 99f6be0663..6738bf0f29 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -90,7 +90,7 @@ public async Task Patching_Through_Relationship_Link_With_Multiple_Resources_Typ data = new[] { new { type = "todoItems", id = 1 }, - new { type = "articles", id = 2 }, + new { type = "articles", id = 2 } } }); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index cba40818a2..cdb74f61e5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -349,7 +349,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = person.StringId, attributes = new Dictionary { - ["lastName"] = "Johnson", + ["lastName"] = "Johnson" } } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 620f5e604b..ab33245072 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -60,7 +60,7 @@ public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Include_Me { TodoItems = new HashSet { - new TodoItem {Id = 1, Description = "Important: Pay the bills"}, + new TodoItem {Id = 1, Description = "Important: Pay the bills"} } }; diff --git a/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs index 215ac7ab8c..9fbe8275f3 100644 --- a/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs +++ b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs @@ -23,7 +23,7 @@ public void Errors_Correctly_Infers_Status_Code() { new Error(HttpStatusCode.OK) {Title = "weird"}, new Error(HttpStatusCode.BadRequest) {Title = "bad"}, - new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"} }; var errors500 = new List @@ -32,7 +32,7 @@ public void Errors_Correctly_Infers_Status_Code() new Error(HttpStatusCode.BadRequest) {Title = "bad"}, new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, new Error(HttpStatusCode.InternalServerError) {Title = "really bad"}, - new Error(HttpStatusCode.BadGateway) {Title = "really bad specific"}, + new Error(HttpStatusCode.BadGateway) {Title = "really bad specific"} }; // Act diff --git a/test/UnitTests/Internal/TypeHelper_Tests.cs b/test/UnitTests/Internal/TypeHelper_Tests.cs index 7185641091..55a539ea44 100644 --- a/test/UnitTests/Internal/TypeHelper_Tests.cs +++ b/test/UnitTests/Internal/TypeHelper_Tests.cs @@ -95,7 +95,7 @@ public void ConvertType_Returns_Default_Value_For_Empty_Strings() { typeof(short), (short)0 }, { typeof(long), (long)0 }, { typeof(string), "" }, - { typeof(Guid), Guid.Empty }, + { typeof(Guid), Guid.Empty } }; foreach (var t in data) diff --git a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs index 896b47b115..bfe862d0ca 100644 --- a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs +++ b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs @@ -262,7 +262,7 @@ public void DeserializeSingle_DeeplyNestedIncluded_CanDeserialize() Type = "oneToManyPrincipals", Id = "10", Attributes = new Dictionary { {"attributeMember", deeplyNestedIncludedAttributeValue } } - }, + } }; var body = JsonConvert.SerializeObject(content); @@ -313,7 +313,7 @@ public void DeserializeList_DeeplyNestedIncluded_CanDeserialize() Type = "oneToManyPrincipals", Id = "10", Attributes = new Dictionary { {"attributeMember", deeplyNestedIncludedAttributeValue } } - }, + } }; var body = JsonConvert.SerializeObject(content); @@ -361,7 +361,7 @@ public void DeserializeSingle_ResourceWithInheritanceAndInclusions_CanDeserializ Type = "firstDerivedModels", Id = "20", Attributes = new Dictionary { { "firstProperty", "true" } } - }, + } }; var body = JsonConvert.SerializeObject(content); diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 2a9991fe73..dbc393a15d 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -28,7 +28,7 @@ public void DeserializeResourceIdentifiers_SingleData_CanDeserialize() Data = new ResourceObject { Type = "testResource", - Id = "1", + Id = "1" } }; var body = JsonConvert.SerializeObject(content); @@ -65,7 +65,7 @@ public void DeserializeResourceIdentifiers_ArrayData_CanDeserialize() new ResourceObject { Type = "testResource", - Id = "1", + Id = "1" } } }; diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs index 58d380cee9..96717222b9 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -91,7 +91,7 @@ public void ResourceWithRelationshipsToResourceObject_ResourceWithId_CanBuild() // Arrange var resource = new MultipleRelationshipsPrincipalPart { - PopulatedToOne = new OneToOneDependent { Id = 10 }, + PopulatedToOne = new OneToOneDependent { Id = 10 } }; // Act From a64cc14248557760127f8dc9e5575af6f4ecaf9a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 14:50:22 +0100 Subject: [PATCH 138/240] rename test --- .../Writing/Creating/CreateResourceWithRelationshipTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index a44e3f13f8..c0f68f2157 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -85,7 +85,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_OneToOne_relationship_from_dependent_side_with_implicit_remove() + public async Task Can_create_resource_with_OneToOne_relationship_from_dependent_side() { // Arrange var existingColor = WriteFakers.RgbColor.Generate(); From 5c4fa960abf87b95c7d28220a8c966113c14bba7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 15:06:43 +0100 Subject: [PATCH 139/240] Fixed: let fakers generate different data on subsequent calls within a single test. --- .../Writing/Creating/CreateResourceTests.cs | 11 ++-- ...reateResourceWithClientGeneratedIdTests.cs | 16 ++--- .../CreateResourceWithRelationshipTests.cs | 31 ++++----- .../Writing/Deleting/DeleteResourceTests.cs | 19 +++--- .../AddToToManyRelationshipTests.cs | 59 ++++++++--------- .../RemoveFromToManyRelationshipTests.cs | 65 ++++++++++--------- .../IntegrationTests/Writing/WriteFakers.cs | 62 +++++++++++------- 7 files changed, 138 insertions(+), 125 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 5050e7faf5..21c0854f08 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -16,6 +16,7 @@ public sealed class CreateResourceTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); public CreateResourceTests(IntegrationTestContext, WriteDbContext> testContext) { @@ -30,7 +31,7 @@ public CreateResourceTests(IntegrationTestContext public async Task Can_create_resource_with_long_ID() { // Arrange - var userAccount = WriteFakers.UserAccount.Generate(); + var userAccount = _fakers.UserAccount.Generate(); var requestBody = new { @@ -163,7 +164,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_guid_ID() { // Arrange - var group = WriteFakers.WorkItemGroup.Generate(); + var group = _fakers.WorkItemGroup.Generate(); var requestBody = new { @@ -256,7 +257,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_unknown_attribute() { // Arrange - var workItem = WriteFakers.WorkItem.Generate(); + var workItem = _fakers.WorkItem.Generate(); var requestBody = new { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index cfac920138..45941063dd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -16,6 +16,7 @@ public sealed class CreateResourceWithClientGeneratedIdTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext, WriteDbContext> testContext) { @@ -29,7 +30,7 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_with_fieldset() { // Arrange - var group = WriteFakers.WorkItemGroup.Generate(); + var group = _fakers.WorkItemGroup.Generate(); group.Id = Guid.NewGuid(); var requestBody = new @@ -120,11 +121,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() { // Arrange - var color = new RgbColor - { - Id = "#FF0000", - DisplayName = "Red" - }; + var color = _fakers.RgbColor.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -170,10 +167,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_resource_for_existing_client_generated_ID() { // Arrange - var existingColor = WriteFakers.RgbColor.Generate(); - existingColor.Id = "#FFFFFF"; + var existingColor = _fakers.RgbColor.Generate(); - var colorToCreate = WriteFakers.RgbColor.Generate(); + var colorToCreate = _fakers.RgbColor.Generate(); colorToCreate.Id = existingColor.Id; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index c0f68f2157..8524c82fc0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -14,6 +14,7 @@ public sealed class CreateResourceWithRelationshipTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); public CreateResourceWithRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) { @@ -27,8 +28,8 @@ public CreateResourceWithRelationshipTests(IntegrationTestContext { @@ -88,8 +89,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_OneToOne_relationship_from_dependent_side() { // Arrange - var existingColor = WriteFakers.RgbColor.Generate(); - existingColor.Group = WriteFakers.WorkItemGroup.Generate(); + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -148,7 +149,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_HasOne_relationship_with_include() { // Arrange - var existingUserAccount = WriteFakers.UserAccount.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -211,8 +212,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_HasOne_relationship_with_include_and_primary_fieldset() { // Arrange - var existingUserAccount = WriteFakers.UserAccount.Generate(); - var workItem = WriteFakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + var workItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -394,7 +395,7 @@ public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() public async Task Can_create_resource_with_HasMany_relationship() { // Arrange - var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + var existingUserAccounts = _fakers.UserAccount.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -461,7 +462,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_HasMany_relationship_with_include() { // Arrange - var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + var existingUserAccounts = _fakers.UserAccount.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -534,7 +535,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_HasMany_relationship_with_include_and_secondary_fieldset() { // Arrange - var existingUserAccounts = WriteFakers.UserAccount.Generate(2); + var existingUserAccounts = _fakers.UserAccount.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -607,7 +608,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_duplicate_HasMany_relationships() { // Arrange - var existingUserAccount = WriteFakers.UserAccount.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -676,8 +677,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_HasManyThrough_relationship_with_include_and_fieldsets() { // Arrange - var existingTags = WriteFakers.WorkTags.Generate(3); - var workItemToCreate = WriteFakers.WorkItem.Generate(); + var existingTags = _fakers.WorkTags.Generate(3); + var workItemToCreate = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -940,8 +941,8 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() public async Task Can_create_resource_with_multiple_relationship_types() { // Arrange - var existingUserAccounts = WriteFakers.UserAccount.Generate(2); - var existingTag = WriteFakers.WorkTags.Generate(); + var existingUserAccounts = _fakers.UserAccount.Generate(2); + var existingTag = _fakers.WorkTags.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index b631e62e0a..ee9125b957 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -13,6 +13,7 @@ public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); public DeleteResourceTests(IntegrationTestContext, WriteDbContext> testContext) { @@ -23,7 +24,7 @@ public DeleteResourceTests(IntegrationTestContext { @@ -73,8 +74,8 @@ public async Task Cannot_delete_missing_resource() public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() { // Arrange - var existingColor = WriteFakers.RgbColor.Generate(); - existingColor.Group = WriteFakers.WorkItemGroup.Generate(); + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -113,8 +114,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_delete_existing_resource_with_OneToOne_relationship_from_principal_side() { // Arrange - var existingGroup = WriteFakers.WorkItemGroup.Generate(); - existingGroup.Color = WriteFakers.RgbColor.Generate(); + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -143,8 +144,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_delete_existing_resource_with_HasMany_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -175,8 +176,8 @@ public async Task Can_delete_resource_with_HasManyThrough_relationship() // Arrange var existingWorkItemTag = new WorkItemTag { - Item = WriteFakers.WorkItem.Generate(), - Tag = WriteFakers.WorkTags.Generate() + Item = _fakers.WorkItem.Generate(), + Tag = _fakers.WorkTags.Generate() }; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index b0a6103d96..57316dec3d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -12,6 +12,7 @@ public sealed class AddToToManyRelationshipTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); public AddToToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) { @@ -22,8 +23,8 @@ public AddToToManyRelationshipTests(IntegrationTestContext { @@ -61,10 +62,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_HasMany_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -112,16 +113,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_HasManyThrough_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.WorkItemTags = new[] { new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() } }; - var existingTags = WriteFakers.WorkTags.Generate(2); + var existingTags = _fakers.WorkTags.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -176,7 +177,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_for_missing_type() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -213,7 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_for_unknown_type() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -251,7 +252,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_for_missing_ID() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -288,7 +289,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_unknown_IDs_to_HasMany_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -336,7 +337,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_unknown_IDs_to_HasManyThrough_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -384,8 +385,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_to_unknown_resource_type_in_url() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -420,8 +421,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_to_unknown_resource_ID_in_url() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -459,7 +460,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_to_unknown_relationship_in_url() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -497,8 +498,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_for_relationship_mismatch_between_url_and_body() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -536,10 +537,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_HasMany_relationship_with_already_attached_resources() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -597,16 +598,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_HasManyThrough_relationship_with_already_attached_resource() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.WorkItemTags = new[] { new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() } }; - var existingTag = WriteFakers.WorkTags.Generate(); + var existingTag = _fakers.WorkTags.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -659,8 +660,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_with_duplicates() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -711,7 +712,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_with_empty_list() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index e417e2a914..310d5499fa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -12,6 +12,7 @@ public sealed class RemoveFromToManyRelationshipTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); public RemoveFromToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) { @@ -22,8 +23,8 @@ public RemoveFromToManyRelationshipTests(IntegrationTestContext { @@ -61,8 +62,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_HasMany_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -112,20 +113,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_HasManyThrough_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.WorkItemTags = new[] { new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() }, new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() }, new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() } }; @@ -183,8 +184,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_for_missing_type() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -221,7 +222,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_for_unknown_type() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -259,7 +260,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_for_missing_ID() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -296,7 +297,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_unknown_IDs_from_HasMany_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -344,7 +345,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_unknown_IDs_from_HasManyThrough_relationship() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -392,8 +393,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_unknown_resource_type_in_url() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -428,8 +429,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_unknown_resource_ID_in_url() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -467,7 +468,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_unknown_relationship_in_url() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -505,8 +506,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_for_relationship_mismatch_between_url_and_body() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -544,9 +545,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_HasMany_relationship_with_unrelated_existing_resource() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); - var existingSubscriber = WriteFakers.UserAccount.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -601,19 +602,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_HasManyThrough_relationship_with_unrelated_existing_resource() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); + var existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.WorkItemTags = new[] { new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() }, new WorkItemTag { - Tag = WriteFakers.WorkTags.Generate() + Tag = _fakers.WorkTags.Generate() } }; - var existingTag = WriteFakers.WorkTags.Generate(); + var existingTag = _fakers.WorkTags.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -669,8 +670,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_with_duplicates() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(2).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -721,8 +722,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_with_empty_list() { // Arrange - var existingWorkItem = WriteFakers.WorkItem.Generate(); - existingWorkItem.Subscribers = WriteFakers.UserAccount.Generate(1).ToHashSet(); + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs index 39038b4932..10d71bc54c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -7,36 +7,48 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { - internal static class WriteFakers + internal class WriteFakers { - public static Faker WorkItem => new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Description, f => f.Lorem.Sentence()) - .RuleFor(p => p.DueAt, f => f.Date.Future()) - .RuleFor(p => p.Priority, f => f.PickRandom()); - - public static Faker WorkTags => new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Text, f => f.Lorem.Word()) - .RuleFor(p => p.IsBuiltIn, f => f.Random.Bool()); - - public static Faker UserAccount => new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - public static Faker WorkItemGroup => new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Name, f => f.Lorem.Word()); - - public static Faker RgbColor => new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(p=>p.Id, f=>f.Random.Hexadecimal(6)) - .RuleFor(p => p.DisplayName, f => f.Lorem.Word()); + private readonly Lazy> _lazyWorkItemFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(p => p.Description, f => f.Lorem.Sentence()) + .RuleFor(p => p.DueAt, f => f.Date.Future()) + .RuleFor(p => p.Priority, f => f.PickRandom())); + + private readonly Lazy> _lazyWorkTagsFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(p => p.Text, f => f.Lorem.Word()) + .RuleFor(p => p.IsBuiltIn, f => f.Random.Bool())); + + private readonly Lazy> _lazyUserAccountFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName())); + + private readonly Lazy> _lazyWorkItemGroupFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(p => p.Name, f => f.Lorem.Word())); + + private readonly Lazy> _lazyRgbColorFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(p => p.Id, f => f.Random.Hexadecimal(6)) + .RuleFor(p => p.DisplayName, f => f.Lorem.Word())); + + public Faker WorkItem => _lazyWorkItemFaker.Value; + public Faker WorkTags => _lazyWorkTagsFaker.Value; + public Faker UserAccount => _lazyUserAccountFaker.Value; + public Faker WorkItemGroup => _lazyWorkItemGroupFaker.Value; + public Faker RgbColor => _lazyRgbColorFaker.Value; private static int GetFakerSeed() { // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. + MethodBase testMethod = GetTestMethod(); var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; From d2421b8bb489c3b384117aac67b0eeb781b3dc71 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 16:22:17 +0100 Subject: [PATCH 140/240] Added tests for update to-one relationships through relationships endpoint --- .../Spec/ResourceTypeMismatchTests.cs | 24 - .../Spec/UpdatingRelationshipsTests.cs | 153 ------ .../UpdateToOneRelationshipTests.cs | 508 ++++++++++++++++++ 3 files changed, 508 insertions(+), 177 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs index 6738bf0f29..a6250e080d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -57,30 +57,6 @@ public async Task Patching_Resource_With_Mismatching_Resource_Type_Returns_Confl Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1', instead of 'people'.", errorDocument.Errors[0].Detail); } - [Fact] - public async Task Patching_Through_Relationship_Link_With_Mismatching_Resource_Type_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "todoItems", - id = 1 - } - }); - - // Act - var (body, _) = await Patch("/api/v1/todoItems/1/relationships/owner", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'people' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/owner', instead of 'todoItems'.", errorDocument.Errors[0].Detail); - } - [Fact] public async Task Patching_Through_Relationship_Link_With_Multiple_Resources_Types_Returns_Conflict() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 4ef433459a..66018893e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -617,89 +617,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_Set_ToOne_Relationship_Through_Relationship_Endpoint() - { - // Arrange - var person = _personFaker.Generate(); - var otherTodoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(person, otherTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person.StringId - } - }; - - var route = $"/api/v1/todoItems/{otherTodoItem.StringId}/relationships/owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.Owner) - .Where(item => item.Id == otherTodoItem.Id) - .FirstAsync(); - - todoItemInDatabase.Owner.Should().NotBeNull(); - todoItemInDatabase.Owner.Id.Should().Be(person.Id); - }); - } - - [Fact] - public async Task Can_Delete_ToOne_Relationship_By_Patching_Through_Relationship_Endpoint() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = (object) null - }; - - var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.Owner) - .Where(item => item.Id == todoItem.Id) - .FirstAsync(); - - todoItemInDatabase.Owner.Should().BeNull(); - }); - } - [Fact] public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { @@ -776,75 +693,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[2].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[2].Detail.Should().Be("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist."); } - - [Fact] - public async Task Fails_When_Patching_On_Relationships_Endpoint_With_Missing_Primary_Resource() - { - // Arrange - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person.StringId - } - }; - - var route = "/api/v1/todoItems/99999999/relationships/owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' with ID '99999999' does not exist."); - } - - [Fact] - public async Task Fails_When_Patching_To_One_Relationship_On_Relationships_Endpoint_With_Missing_Secondary_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = "9999999" - } - }; - - var route = $"/api/v1/todoItems/{todoItem.StringId}/relationships/owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '9999999' being assigned to relationship 'owner' does not exist."); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..5b61bf4848 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,508 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class UpdateToOneRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateToOneRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_HasOne_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.AssignedTo.Should().BeNull(); + + var userAccountInDatabase = await dbContext.UserAccounts + .Include(userAccount => userAccount.AssignedItems) + .FirstOrDefaultAsync(userAccount => userAccount.Id == existingWorkItem.AssignedTo.Id); + + userAccountInDatabase.Should().NotBeNull(); + userAccountInDatabase.AssignedItems.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingGroup, existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId + } + }; + + var route = $"/rgbColors/{existingColor.StringId}/relationships/group"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); + colorInDatabase1.Group.Should().BeNull(); + + var colorInDatabase2 = colorsInDatabase.Single(p => p.Id == existingColor.Id); + colorInDatabase2.Group.Should().NotBeNull(); + colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroups = _fakers.WorkItemGroup.Generate(2); + existingGroups[0].Color = _fakers.RgbColor.Generate(); + existingGroups[1].Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroups); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingGroups[0].Color.StringId + } + }; + + var route = $"/workItemGroups/{existingGroups[1].StringId}/relationships/color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var groupInDatabase1 = groupsInDatabase.Single(p => p.Id == existingGroups[0].Id); + groupInDatabase1.Color.Should().BeNull(); + + var groupInDatabase2 = groupsInDatabase.Single(p => p.Id == existingGroups[1].Id); + groupInDatabase2.Color.Should().NotBeNull(); + groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); + + var colorsInDatabase = await dbContext.RgbColors + .Include(color => color.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color.Id); + colorInDatabase1.Group.Should().NotBeNull(); + colorInDatabase1.Group.Id.Should().Be(existingGroups[1].Id); + + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingGroups[1].Color.Id); + colorInDatabase2.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + existingUserAccounts[0].AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + }; + + var route = $"/workItems/{existingUserAccounts[0].AssignedItems.ElementAt(1).StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .ToListAsync(); + + var workItemInDatabase2 = workItemsInDatabase.Single(p => p.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); + workItemInDatabase2.AssignedTo.Should().NotBeNull(); + workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); + + var userAccountsInDatabase = await dbContext.UserAccounts + .Include(userAccount => userAccount.AssignedItems) + .ToListAsync(); + + var userAccountInDatabase1 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[0].Id); + userAccountInDatabase1.AssignedItems.Should().HaveCount(1); + userAccountInDatabase1.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(0).Id); + + var userAccountInDatabase2 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[1].Id); + userAccountInDatabase2.AssignedItems.Should().HaveCount(1); + userAccountInDatabase2.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "doesNotExist", + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_create_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts" + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_unknown_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = 88888888 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'assignedTo' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_create_on_unknown_resource_ID_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + }; + + var route = "/workItems/99999999/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_create_on_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'userAccounts' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/assignedTo', instead of 'rgbColors'."); + } + } +} From 575d66a72431434663b8be865278934c919d2bc4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 16:33:42 +0100 Subject: [PATCH 141/240] Refactor tests: .Where(expr).FirstAsync() -> .FirstAsync(expr) --- .../Spec/UpdatingRelationshipsTests.cs | 24 +++++++------------ .../ModelStateValidationTests.cs | 3 +-- .../AddToToManyRelationshipTests.cs | 18 +++++--------- .../RemoveFromToManyRelationshipTests.cs | 18 +++++--------- 4 files changed, 21 insertions(+), 42 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 66018893e2..36993a37b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -85,8 +85,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems .Include(item => item.ChildrenTodos) - .Where(item => item.Id == todoItem.Id) - .FirstAsync(); + .FirstAsync(item => item.Id == todoItem.Id); todoItemInDatabase.ChildrenTodos.Should().HaveCount(2); todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == todoItem.Id); @@ -138,8 +137,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems .Include(item => item.DependentOnTodo) - .Where(item => item.Id == todoItem.Id) - .FirstAsync(); + .FirstAsync(item => item.Id == todoItem.Id); todoItemInDatabase.DependentOnTodoId.Should().Be(todoItem.Id); }); @@ -206,8 +204,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems .Include(item => item.ParentTodo) - .Where(item => item.Id == todoItem.Id) - .FirstAsync(); + .FirstAsync(item => item.Id == todoItem.Id); todoItemInDatabase.ParentTodoId.Should().Be(todoItem.Id); }); @@ -347,8 +344,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var todoCollectionInDatabase = await dbContext.TodoItemCollections .Include(collection => collection.TodoItems) - .Where(collection => collection.Id == todoCollection.Id) - .FirstAsync(); + .FirstAsync(collection => collection.Id == todoCollection.Id); todoCollectionInDatabase.TodoItems.Should().HaveCount(2); }); @@ -395,8 +391,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems .Include(item => item.Owner) - .Where(item => item.Id == todoItem.Id) - .FirstAsync(); + .FirstAsync(item => item.Id == todoItem.Id); todoItemInDatabase.Owner.Should().BeNull(); }); @@ -447,8 +442,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var todoItemInDatabase = await dbContext.TodoItems .Include(item => item.Owner) - .Where(item => item.Id == todoItem.Id) - .FirstAsync(); + .FirstAsync(item => item.Id == todoItem.Id); todoItemInDatabase.Owner.Should().NotBeNull(); todoItemInDatabase.Owner.Id.Should().Be(person.Id); @@ -501,8 +495,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) - .FirstAsync(); + .FirstAsync(p => p.Id == person.Id); personInDatabase.TodoItems.Should().BeEmpty(); }); @@ -609,8 +602,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var personInDatabase = await dbContext.People .Include(p => p.TodoItems) - .Where(p => p.Id == person.Id) - .FirstAsync(); + .FirstAsync(p => p.Id == person.Id); personInDatabase.TodoItems.Should().HaveCount(1); personInDatabase.TodoItems.ElementAt(0).Id.Should().Be(otherTodoItem.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index c735f980fa..412e9dada1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -826,8 +826,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var directoryInDatabase = await dbContext.Directories .Include(d => d.Parent) - .Where(d => d.Id == directory.Id) - .FirstAsync(); + .FirstAsync(d => d.Id == directory.Id); directoryInDatabase.Parent.Id.Should().Be(otherParent.Id); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 57316dec3d..2a1902e3f7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -99,8 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(3); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); @@ -163,8 +162,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.WorkItemTags.Should().HaveCount(3); workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); @@ -584,8 +582,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(3); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); @@ -647,8 +644,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.WorkItemTags.Should().HaveCount(2); workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); @@ -700,8 +696,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); @@ -739,8 +734,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(0); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 310d5499fa..a1a38b4d91 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -98,8 +98,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); @@ -169,8 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.WorkItemTags.Should().HaveCount(1); workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(2).Tag.Id); @@ -587,8 +585,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); @@ -655,8 +652,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.WorkItemTags.Should().HaveCount(1); workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); @@ -710,8 +706,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); @@ -750,8 +745,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .Where(workItem => workItem.Id == existingWorkItem.Id) - .FirstAsync(); + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); From 6a3ed7932fbcb72c2c59b68414131eefc5162505 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 17:17:31 +0100 Subject: [PATCH 142/240] Added tests for updating to-many relationships through relationships endpoint --- .../Services/JsonApiResourceService.cs | 6 + .../Acceptance/ManyToManyTests.cs | 51 -- .../Spec/ResourceTypeMismatchTests.cs | 24 - .../Spec/UpdatingRelationshipsTests.cs | 48 -- .../ReplaceToManyRelationshipTests.cs | 690 ++++++++++++++++++ 5 files changed, 696 insertions(+), 123 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0db4f39ab5..48dda82b1b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -310,6 +310,12 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); + if (_request.Relationship is HasManyAttribute && secondaryResourceIds == null) + { + // TODO: Usage of InvalidRequestBodyException (here and in BaseJsonApiController) is probably not the nest choice, because they do not contain request body. + throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", null, null); + } + TResource primaryResource = null; if (_hookExecutor != null) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 2f18cb1486..0aa85a33b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -225,56 +225,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); }); } - - [Fact] - public async Task Can_Set_HasManyThrough_Relationship_Through_Relationships_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - var existingTag = _tagFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingArticleTag, existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "tags", - id = existingTag.StringId - } - } - }; - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - - articleInDatabase.ArticleTags.Should().HaveCount(1); - articleInDatabase.ArticleTags.Single().TagId.Should().Be(existingTag.Id); - }); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs index a6250e080d..327f783954 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -56,29 +56,5 @@ public async Task Patching_Resource_With_Mismatching_Resource_Type_Returns_Confl Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1', instead of 'people'.", errorDocument.Errors[0].Detail); } - - [Fact] - public async Task Patching_Through_Relationship_Link_With_Multiple_Resources_Types_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new[] - { - new { type = "todoItems", id = 1 }, - new { type = "articles", id = 2 } - } - }); - - // Act - var (body, _) = await Patch("/api/v1/todoItems/1/relationships/childrenTodos", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/childrenTodos', instead of 'articles'.", errorDocument.Errors[0].Detail); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 36993a37b5..50f3ab29a8 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -561,54 +561,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_Set_ToMany_Relationship_Through_Relationship_Endpoint() - { - // Arrange - var person = _personFaker.Generate(); - person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - var otherTodoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(person, otherTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "todoItems", - id = otherTodoItem.StringId - } - } - }; - - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .FirstAsync(p => p.Id == person.Id); - - personInDatabase.TodoItems.Should().HaveCount(1); - personInDatabase.TodoItems.ElementAt(0).Id.Should().Be(otherTodoItem.Id); - }); - } - [Fact] public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..8eee13da1f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,690 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class ReplaceToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public ReplaceToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingTags = _fakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(2); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + + [Fact] + public async Task Cannot_replace_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_replace_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_with_unknown_IDs_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_replace_with_unknown_IDs_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workTags' with ID '88888888' being assigned to relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_replace_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_replace_on_unknown_resource_ID_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = "/workItems/99999999/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_replace_on_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_replace_on_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + } + + [Fact] + public async Task Can_replace_with_duplicates() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} From cb75a0b53b3c2983bd952a74d27daecf460d1105 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 17:31:26 +0100 Subject: [PATCH 143/240] fixed: missing null check --- .../Serialization/BaseDeserializer.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 407d42fd64..dbc8d82f3f 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -50,22 +50,20 @@ protected object DeserializeBody(string body) var bodyJToken = LoadJToken(body); Document = bodyJToken.ToObject(); - if (Document.IsManyData) + if (Document != null) { - if (Document.ManyData.Count == 0) + if (Document.IsManyData) { - return new HashSet(); + return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); } - return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); - } - - if (Document.SingleData == null) - { - return null; + if (Document.SingleData != null) + { + return ParseResourceObject(Document.SingleData); + } } - return ParseResourceObject(Document.SingleData); + return null; } /// From f2f8dcb29806bd66dac62483dffe6ffd6cd1bd86 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Oct 2020 17:45:11 +0100 Subject: [PATCH 144/240] Fix for disabled test: Cannot_remove_for_relationship_mismatch_between_url_and_body --- src/JsonApiDotNetCore/Serialization/JsonApiReader.cs | 7 +------ .../Relationships/RemoveFromToManyRelationshipTests.cs | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 7e6f2234b4..ee06034d5e 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -78,7 +78,7 @@ public async Task ReadAsync(InputFormatterContext context) private void ValidateIncomingResourceType(InputFormatterContext context, object model) { - if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) + if (context.HttpContext.IsJsonApiRequest() && context.HttpContext.Request.Method != HttpMethods.Get) { var endpointResourceType = GetEndpointResourceType(); if (endpointResourceType == null) @@ -170,11 +170,6 @@ private async Task GetRequestBody(Stream body) // https://github.com/aspnet/AspNetCore/issues/7644 return await reader.ReadToEndAsync(); } - - private bool IsPatchOrPostRequest(HttpRequest request) - { - return request.Method == HttpMethods.Patch || request.Method == HttpMethods.Post; - } private IEnumerable GetBodyResourceTypes(object model) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index a1a38b4d91..7653f233d5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -500,7 +500,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_remove_for_relationship_mismatch_between_url_and_body() { // Arrange From f8a87d3fafb3e1259441bb0e203397f1758ea5ea Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 26 Oct 2020 21:55:48 +0100 Subject: [PATCH 145/240] feat: remove already related resources from assignment with expression trees --- .../Data/AppDbContext.cs | 3 +- .../QueryableBuilding/WhereClauseBuilder.cs | 3 +- .../Repositories/DbContextExtensions.cs | 13 ++ .../EntityFrameworkCoreRepository.cs | 163 ++++++++++++++---- .../Resources/Annotations/HasManyAttribute.cs | 7 - .../Annotations/HasManyThroughAttribute.cs | 53 +----- .../Serialization/BaseDeserializer.cs | 5 +- .../Building/ResourceObjectBuilder.cs | 2 +- .../Acceptance/ManyToManyTests.cs | 28 +-- .../Spec/UpdatingRelationshipsTests.cs | 1 + 10 files changed, 165 insertions(+), 113 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index fcfe431211..1b9b965c2c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -44,8 +44,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(t => t.Owner) - .WithMany(p => p.TodoItems) - /*.HasForeignKey(t => t.OwnerId)*/; + .WithMany(p => p.TodoItems); modelBuilder.Entity() .HasKey(bc => new { bc.ArticleId, bc.TagId }); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 9ed848053f..8a91371828 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -19,7 +19,7 @@ public class WhereClauseBuilder : QueryClauseBuilder private readonly Expression _source; private readonly Type _extensionType; - public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) : base(lambdaScope) { _source = source ?? throw new ArgumentNullException(nameof(source)); @@ -95,6 +95,7 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type a public override Expression VisitEqualsAnyOf(EqualsAnyOfExpression expression, Type argument) { + Expression property = Visit(expression.TargetAttribute, argument); var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type)); diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index d2a980791e..bc44b6238b 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -36,5 +36,18 @@ public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifia return entityEntry?.Entity; } + + public static IQueryable Set(this DbContext dbContext, Type entityType) + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (entityType == null) throw new ArgumentNullException(nameof(entityType)); + + var getDbSetOpen = typeof(DbContext).GetMethod(nameof(DbContext.Set)); + + var getDbSetGeneric = getDbSetOpen!.MakeGenericMethod(entityType); + var dbSet = (IQueryable)getDbSetGeneric.Invoke(dbContext, null); + + return dbSet; + } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 62cc8fadc9..dc51f24ef5 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -13,7 +14,6 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -163,7 +163,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); var currentRightResourcesCount = rightResources.Count; rightResources.ExceptWith(secondaryResourceIds); - // TODO: with introduction of HasManyAttribute.GetManyValue, I think we don't have to abstract away subtraction of two sets any longer. - // var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, secondaryResourceIds); - // todo: why has it been reverted to != again? bool hasChanges = rightResources.Count != currentRightResourcesCount; if (hasChanges) @@ -258,6 +255,8 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet /// Removes resources from whose ID exists in . /// @@ -351,38 +350,126 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi // If the left resource is the dependent side of the relationship, complete replacement is already guaranteed. if (!HasForeignKeyAtLeftSide(relationship)) { - var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); - await navigationEntry.LoadAsync(); + if (relationship is HasManyThroughAttribute hasManyThroughRelationship) + { + await _dbContext + .Set() + .Include(relationship.RelationshipPath) + .FirstAsync(r => r.Id.Equals(resource.Id)); + } + else + { + var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); + await navigationEntry.LoadAsync(); + } + } } private void FlushFromCache(IIdentifiable resource) { var trackedResource = _dbContext.GetTrackedIdentifiable(resource); - _dbContext.Entry(trackedResource).State = EntityState.Detached; + Detach(trackedResource); + } + + private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, ISet secondaryResourceIds) + { + object[] throughEntities; + + throughEntities = await GetRightResourcesWhereIdInSet_GenericCall(hasManyThroughRelationship, secondaryResourceIds); + + // throughEntities = await GetRightResourcesWhereIdInSet_DynamicCall(hasManyThroughRelationship, secondaryResourceIds); + + // throughEntities = await GetRightResourcesWhereIdInSet_QueryBuilderCall(hasManyThroughRelationship, secondaryResourceIds); + + + var rightResources = throughEntities.Select(entity => ConstructRightResourceOfHasManyRelationship(entity, hasManyThroughRelationship)).ToHashSet(); + secondaryResourceIds.ExceptWith(rightResources); + + Detach(throughEntities); } - private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, TId primaryResourceId, ISet secondaryResourceIds) + private async Task GetRightResourcesWhereIdInSet_GenericCall(HasManyThroughAttribute hasManyThroughRelationship, ISet secondaryResourceIds) + { + var genericCallMethod = GetType().GetMethod(nameof(GetRightResourcesWhereIdInSet_GenericCall))!.MakeGenericMethod(hasManyThroughRelationship.ThroughType); + object[] throughEntities = await (dynamic) genericCallMethod.Invoke(this, new object[] {hasManyThroughRelationship, secondaryResourceIds}); + + return throughEntities; + } + + public async Task GetRightResourcesWhereIdInSet_GenericCall(HasManyThroughAttribute relationship, ISet secondaryResourceIds) where TThroughType : class + { + var namingFactory = new LambdaParameterNameFactory(); + var throughEntityParameter = Expression.Parameter(relationship.ThroughType, namingFactory.Create(relationship.ThroughType.Name).Name); + + var containsCall = GetContainsCall(relationship, secondaryResourceIds, throughEntityParameter); + + var containsPredicate = Expression.Lambda>(containsCall, throughEntityParameter); + var result = await _dbContext.Set().Where(containsPredicate).ToListAsync(); + + return result.Cast().ToArray(); + } + + private async Task GetRightResourcesWhereIdInSet_DynamicCall(HasManyThroughAttribute relationship, ISet secondaryResourceIds) + { + var namingFactory = new LambdaParameterNameFactory(); + var parameterName = namingFactory.Create(relationship.ThroughType.Name).Name; + var throughEntityParameter = Expression.Parameter(relationship.ThroughType, parameterName); + + var containsCall = GetContainsCall(relationship, secondaryResourceIds, throughEntityParameter); + var containsPredicate = Expression.Lambda(containsCall, throughEntityParameter); + + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, containsPredicate); + + dynamic query = throughSource.Provider.CreateQuery(whereClause); + IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return result.Cast().ToArray(); + } + + private async Task GetRightResourcesWhereIdInSet_QueryBuilderCall(HasManyThroughAttribute relationship, ISet secondaryResourceIds) { - // TODO: This is a no-go, because it loads the complete set of related entities, which can be massive. - // Instead, it should only load the subset of related entities that is in secondaryResourceIds, and the deduce what still needs to be added. + var targetAttributes = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty, PublicName = relationship.RightIdProperty.Name }); + var ids = secondaryResourceIds.Select(r => new LiteralConstantExpression(r.StringId)).ToList().AsReadOnly(); + var equalsAnyOf = new EqualsAnyOfExpression(targetAttributes, ids); + + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + + var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); + var scope = scopeFactory.CreateScope(relationship.ThroughType); - // => What you're describing is not possible because we cannot be sure that ArticleTags is defined as a DbSet on DbContext. - // We would need to load them through filtered includes, for which we need to wait for EF Core 5. + var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); + var whereClause = whereClauseBuilder.ApplyWhere(equalsAnyOf); - var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(primaryResourceId)); + dynamic query = throughSource.Provider.CreateQuery(whereClause); + IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return result.Cast().ToArray(); + } + + private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity, HasManyThroughAttribute relationship) + { + var rightResource = _resourceFactory.CreateInstance(relationship.RightType); + rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); - var navigationEntry = GetNavigationEntryForRelationship(hasManyThroughRelationship, primaryResource); - await navigationEntry.LoadAsync(); + return rightResource; + } + + private MethodCallExpression GetContainsCall(HasManyThroughAttribute relationship, + ISet secondaryResourceIds, ParameterExpression throughEntityParameter) + { - var existingRightResources = hasManyThroughRelationship.GetManyValue(primaryResource, _resourceFactory).ToHashSet(IdentifiableComparer.Instance); - secondaryResourceIds.ExceptWith(existingRightResources); + var tagIdProperty = Expression.Property(throughEntityParameter, relationship.RightIdProperty.Name); - _dbContext.Entry(primaryResource).State = EntityState.Detached; - foreach (var resource in existingRightResources) - { - _dbContext.Entry(resource).State = EntityState.Detached; - } + var intType = relationship.RightIdProperty.PropertyType; + var typedIds = TypeHelper.CopyToList(secondaryResourceIds.Select(r => r.GetTypedId()), intType); + var idCollectionConstant = Expression.Constant(typedIds); + + var containsCall = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] {intType}, + idCollectionConstant, tagIdProperty); + + return containsCall; } private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) @@ -391,10 +478,10 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute switch (relationship) { - case HasManyThroughAttribute hasManyThroughRelationship: - { - return entityEntry.Collection(hasManyThroughRelationship.ThroughProperty.Name); - } + // case HasManyThroughAttribute hasManyThroughRelationship: + // { + // return entityEntry.Collection(hasManyThroughRelationship.ThroughProperty.Name); + // } case HasManyAttribute hasManyRelationship: { return entityEntry.Collection(hasManyRelationship.Property.Name); @@ -407,7 +494,7 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute return null; } - + /// /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. /// @@ -446,7 +533,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio relationship.SetValue(leftResource, placeholderRightResource, _resourceFactory); _dbContext.Entry(leftResource).DetectChanges(); - _dbContext.Entry(placeholderRightResource).State = EntityState.Detached; + Detach(placeholderRightResource); } private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) @@ -477,7 +564,7 @@ private object TryGetValueForProperty(PropertyInfo propertyInfo) if (Nullable.GetUnderlyingType(propertyType) != null) { var underlyingType = propertyInfo.PropertyType.GetGenericArguments()[0]; - + // TODO: Write test with primary key property type int? or equivalent. return Activator.CreateInstance(underlyingType); } @@ -526,16 +613,22 @@ private void DetachRelationships(TResource resource) if (rightValue is IEnumerable rightResources) { - foreach (var rightResource in rightResources) - { - _dbContext.Entry(rightResource).State = EntityState.Detached; - } + Detach(rightResources.ToArray()); } else if (rightValue != null) { + Detach(rightValue); _dbContext.Entry(rightValue).State = EntityState.Detached; } } } + + private void Detach(params object[] entities) + { + foreach (var entity in entities) + { + _dbContext.Entry(entity).State = EntityState.Detached; + } + } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 9bf62425f7..8aa4975432 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -25,12 +25,5 @@ public HasManyAttribute() { Links = LinkTypes.All; } - - internal virtual IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) - { - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - return (IEnumerable)base.GetValue(resource); - } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index afd51f822c..75e9628c5e 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -106,17 +106,6 @@ public HasManyThroughAttribute(string throughPropertyName) /// public override object GetValue(object resource) { - - // if (resource == null) throw new ArgumentNullException(nameof(resource)); - // - // // TODO: Passing null for the resourceFactory parameter is wrong here. Instead, GetManyValue() should properly throw when null is passed in. - // return GetManyValue(resource); - - // The resouceFactory argument needs to be an optional param independent of this method calling it. - // In should actually be the responsibility of the relationship attribute to know whether to use the resource factory or not, - // instead of the caller passing it along. But this is hard because we're working with attributes rather than having a meta abstraction / service - // We can consider working around it with a static internal setter. - if (resource == null) throw new ArgumentNullException(nameof(resource)); IEnumerable throughEntities = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); @@ -126,53 +115,13 @@ public override object GetValue(object resource) .Select(te => RightProperty.GetValue(te)); return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); - - - } - - internal override IEnumerable GetManyValue(object resource, IResourceFactory resourceFactory = null) - { - // TODO: This method contains surprising code: Instead of returning the contents of a collection, - // it modifies data and performs logic that is highly specific to what EntityFrameworkCoreRepository needs. - // => We cannot around this logic and data modification: we must perform a transformation of this collection before returning it. - // The added bit is only an extension of this. It is not EF Core specific but JADNC specific. - // I think it is relevant because only including Article.ArticleTag rather than Article.ArticleTag.Tag is the equivalent - // of having a primary ID only projection on the secondary resource. - // This method is not reusable at all, it should not be concerned if resources are loaded, so should be moved into the caller instead. - // => There are already some cases of it being reused - // After moving the code, the unneeded copying into new collections multiple times can be removed too. - // => I don't think we can. There is no guarantee that a dev uses the same collection type for the join entities and right resource collections. - - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - var value = ThroughProperty.GetValue(resource); - - var throughEntities = value == null ? Array.Empty() : ((IEnumerable)value).Cast().ToArray(); - var rightResourcesAreLoaded = throughEntities.Any() && RightProperty.GetValue(throughEntities.First()) != null; - - // Even if the right resources aren't loaded, we can still construct identifier objects using the ID set on the through entity. - var rightResources = rightResourcesAreLoaded - ? throughEntities.Select(e => RightProperty.GetValue(e)).Cast() - : throughEntities.Select(e => CreateRightResourceWithId(e, resourceFactory)); - - return (IEnumerable)TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); - } - - private IIdentifiable CreateRightResourceWithId(object throughEntity, IResourceFactory resourceFactory) - { - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); - - var rightResource = resourceFactory.CreateInstance(RightType); - rightResource.StringId = RightIdProperty.GetValue(throughEntity)!.ToString(); - - return rightResource; } /// /// Traverses through the provided resource and sets the value of the relationship on the other side of the through type. /// In the example described above, this would be the value of "Articles.ArticleTags.Tag". /// - public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) // TODO: delete resource factory: is this possible? { if (resource == null) throw new ArgumentNullException(nameof(resource)); if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 98127132b8..d2a97345f6 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -14,6 +14,9 @@ namespace JsonApiDotNetCore.Serialization { + // TODO: check if FK assignments are still required. + // TODO: add test with duplicate dictionary entry in body. + /// /// Abstract base class for deserialization. Deserializes JSON content into s /// and constructs instances of the resource(s) in the document body. @@ -21,7 +24,7 @@ namespace JsonApiDotNetCore.Serialization public abstract class BaseDeserializer { protected IResourceContextProvider ResourceContextProvider { get; } - protected IResourceFactory ResourceFactory{ get; } + protected IResourceFactory ResourceFactory { get; } protected Document Document { get; set; } protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 0460cf7a6f..a73287ac3a 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -91,7 +91,7 @@ private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttrib /// private List GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) { - var relatedResources = relationship.GetManyValue(resource); + var relatedResources = (IEnumerable)relationship.GetValue(resource); var manyData = new List(); if (relatedResources != null) { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index a2adca1f9c..21ebf4bdc6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -334,18 +334,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_Add_Already_Related_Resource_Without_It_Being_Readded_To_HasManyThrough_Relationship_Through_Relationships_Endpoint() { // Arrange - var existingArticle = _articleFaker.Generate(); - existingArticle.ArticleTags = new HashSet + var article = _articleFaker.Generate(); + article.ArticleTags = new HashSet { - new ArticleTag {Tag = _tagFaker.Generate()}, - new ArticleTag {Tag = _tagFaker.Generate()} + new ArticleTag { Tag = _tagFaker.Generate() }, + new ArticleTag { Tag = _tagFaker.Generate() } }; - var existingTag = _tagFaker.Generate(); + var tag = _tagFaker.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingArticle, existingTag); + dbContext.AddRange(article, tag); await dbContext.SaveChangesAsync(); }); @@ -356,17 +356,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "tags", - id = existingArticle.ArticleTags.ElementAt(1).Tag.StringId + id = article.ArticleTags.ElementAt(0).Tag.StringId }, new { type = "tags", - id = existingTag.StringId + id = tag.StringId } } }; - var route = $"/api/v1/articles/{existingArticle.StringId}/relationships/tags"; + var route = $"/api/v1/articles/{article.StringId}/relationships/tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -379,13 +379,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticle.Id); + .Include(a => a.ArticleTags) + .FirstAsync(a => a.Id == article.Id); articleInDatabase.ArticleTags.Should().HaveCount(3); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticle.ArticleTags.ElementAt(0).Tag.Id); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticle.ArticleTags.ElementAt(1).Tag.Id); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == article.ArticleTags.ElementAt(0).Tag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == article.ArticleTags.ElementAt(1).Tag.Id); + articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == tag.Id); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 9a6dd8ed82..3db7cc9c7b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -752,6 +752,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_Add_Already_Related_Resource_To_ToMany_Relationship_Through_Relationship_Endpoint_Without_It_Being_Readded() { + // Arrange var person = _personFaker.Generate(); person.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); From fff7aa15cd8211cf254adfd3dc765c2ce8e6616e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 10:16:10 +0100 Subject: [PATCH 146/240] cleanup --- .../Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs | 3 +-- src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs | 2 +- .../Repositories/EntityFrameworkCoreRepository.cs | 5 ++--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 8a91371828..9ed848053f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -19,7 +19,7 @@ public class WhereClauseBuilder : QueryClauseBuilder private readonly Expression _source; private readonly Type _extensionType; - public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) : base(lambdaScope) { _source = source ?? throw new ArgumentNullException(nameof(source)); @@ -95,7 +95,6 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type a public override Expression VisitEqualsAnyOf(EqualsAnyOfExpression expression, Type argument) { - Expression property = Visit(expression.TargetAttribute, argument); var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type)); diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index bc44b6238b..04b418f7dc 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -44,7 +44,7 @@ public static IQueryable Set(this DbContext dbContext, Type entityType) var getDbSetOpen = typeof(DbContext).GetMethod(nameof(DbContext.Set)); - var getDbSetGeneric = getDbSetOpen!.MakeGenericMethod(entityType); + var getDbSetGeneric = getDbSetOpen.MakeGenericMethod(entityType); var dbSet = (IQueryable)getDbSetGeneric.Invoke(dbContext, null); return dbSet; diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index dc51f24ef5..8ad57caed7 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -362,7 +362,6 @@ await _dbContext var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); await navigationEntry.LoadAsync(); } - } } @@ -391,7 +390,7 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt private async Task GetRightResourcesWhereIdInSet_GenericCall(HasManyThroughAttribute hasManyThroughRelationship, ISet secondaryResourceIds) { - var genericCallMethod = GetType().GetMethod(nameof(GetRightResourcesWhereIdInSet_GenericCall))!.MakeGenericMethod(hasManyThroughRelationship.ThroughType); + var genericCallMethod = GetType().GetMethod(nameof(GetRightResourcesWhereIdInSet_GenericCall)).MakeGenericMethod(hasManyThroughRelationship.ThroughType); object[] throughEntities = await (dynamic) genericCallMethod.Invoke(this, new object[] {hasManyThroughRelationship, secondaryResourceIds}); return throughEntities; @@ -494,7 +493,7 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute return null; } - + /// /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. /// From 4fcc8e82e3dae144f9cd5ae640b63d1854afd0af Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 27 Oct 2020 10:31:13 +0100 Subject: [PATCH 147/240] chore: improved GetFilteredThroughEntities call and reused it in EnableCompleteReplacement --- .../Expressions/EqualsAnyOfExpression.cs | 9 +- .../EntityFrameworkCoreRepository.cs | 104 +++++++++++------- .../AddToToManyRelationshipTests.cs | 33 +++--- 3 files changed, 90 insertions(+), 56 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs index 134d8cebdd..9c50f2877a 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -20,10 +20,11 @@ public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); Constants = constants ?? throw new ArgumentNullException(nameof(constants)); - if (constants.Count < 2) - { - throw new ArgumentException("At least two constants are required.", nameof(constants)); - } + // TODO: Why is this necessary? Removing this does not lead to failing tests + // if (constants.Count < 2) + // { + // throw new ArgumentException("At least two constants are required.", nameof(constants)); + // } } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index dc51f24ef5..6023177bc3 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -163,7 +163,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId id, ISet() - .Include(relationship.RelationshipPath) - .FirstAsync(r => r.Id.Equals(resource.Id)); + var throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, resource.Id, null); + hasManyThroughRelationship.ThroughProperty.SetValue(resource, TypeHelper.CopyToTypedCollection(throughEntities, hasManyThroughRelationship.ThroughProperty.PropertyType)); + + foreach (var throughEntity in throughEntities) + { + var rightResource = ConstructRightResourceOfHasManyRelationship(throughEntity, hasManyThroughRelationship); + hasManyThroughRelationship.RightProperty.SetValue(throughEntity, rightResource); + } } else { var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); await navigationEntry.LoadAsync(); } - } } @@ -372,15 +375,15 @@ private void FlushFromCache(IIdentifiable resource) Detach(trackedResource); } - private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, ISet secondaryResourceIds) + private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, TId primaryResourceId, ISet secondaryResourceIds) { object[] throughEntities; - throughEntities = await GetRightResourcesWhereIdInSet_GenericCall(hasManyThroughRelationship, secondaryResourceIds); + throughEntities = await GetFilteredThroughEntities_GenericCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - // throughEntities = await GetRightResourcesWhereIdInSet_DynamicCall(hasManyThroughRelationship, secondaryResourceIds); + throughEntities = await GetFilteredThroughEntities_DynamicCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - // throughEntities = await GetRightResourcesWhereIdInSet_QueryBuilderCall(hasManyThroughRelationship, secondaryResourceIds); + throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); var rightResources = throughEntities.Select(entity => ConstructRightResourceOfHasManyRelationship(entity, hasManyThroughRelationship)).ToHashSet(); @@ -389,38 +392,51 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt Detach(throughEntities); } - private async Task GetRightResourcesWhereIdInSet_GenericCall(HasManyThroughAttribute hasManyThroughRelationship, ISet secondaryResourceIds) + private async Task GetFilteredThroughEntities_GenericCall(HasManyThroughAttribute hasManyThroughRelationship, TId leftIdFilter, ISet rightIdFilter) { - var genericCallMethod = GetType().GetMethod(nameof(GetRightResourcesWhereIdInSet_GenericCall))!.MakeGenericMethod(hasManyThroughRelationship.ThroughType); - object[] throughEntities = await (dynamic) genericCallMethod.Invoke(this, new object[] {hasManyThroughRelationship, secondaryResourceIds}); + var openGenericCallMethod = GetType().GetMethod(nameof(GetFilteredThroughEntities_GenericCall)); + if (openGenericCallMethod != null) + { + var genericCallMethod = openGenericCallMethod.MakeGenericMethod(hasManyThroughRelationship.ThroughType); + var task = genericCallMethod.Invoke(this, new object[] {hasManyThroughRelationship, leftIdFilter, rightIdFilter}); + if (task != null) + { + return await (dynamic) task; + } + } - return throughEntities; + throw new InvalidOperationException(); } - public async Task GetRightResourcesWhereIdInSet_GenericCall(HasManyThroughAttribute relationship, ISet secondaryResourceIds) where TThroughType : class + public async Task GetFilteredThroughEntities_GenericCall(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) where TThroughType : class { var namingFactory = new LambdaParameterNameFactory(); var throughEntityParameter = Expression.Parameter(relationship.ThroughType, namingFactory.Create(relationship.ThroughType.Name).Name); - var containsCall = GetContainsCall(relationship, secondaryResourceIds, throughEntityParameter); - - var containsPredicate = Expression.Lambda>(containsCall, throughEntityParameter); - var result = await _dbContext.Set().Where(containsPredicate).ToListAsync(); + var containsCall = GetContainsCall(relationship, throughEntityParameter, rightIdFilter); + var equalsCall = GetEqualsCall(relationship, throughEntityParameter, leftIdFilter); + var conjunction = Expression.AndAlso(equalsCall, containsCall); + + var predicate = Expression.Lambda>(conjunction, throughEntityParameter); + var result = await _dbContext.Set().Where(predicate).ToListAsync(); return result.Cast().ToArray(); } - - private async Task GetRightResourcesWhereIdInSet_DynamicCall(HasManyThroughAttribute relationship, ISet secondaryResourceIds) + + private async Task GetFilteredThroughEntities_DynamicCall(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) { var namingFactory = new LambdaParameterNameFactory(); var parameterName = namingFactory.Create(relationship.ThroughType.Name).Name; var throughEntityParameter = Expression.Parameter(relationship.ThroughType, parameterName); - var containsCall = GetContainsCall(relationship, secondaryResourceIds, throughEntityParameter); - var containsPredicate = Expression.Lambda(containsCall, throughEntityParameter); + var containsCall = GetContainsCall(relationship, throughEntityParameter, secondaryResourceIds) ; + var equalsCall = GetEqualsCall(relationship, throughEntityParameter, primaryResourceId); + var conjunction = Expression.AndAlso(equalsCall, containsCall); + var predicate = Expression.Lambda(conjunction, throughEntityParameter); + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, containsPredicate); + var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); dynamic query = throughSource.Provider.CreateQuery(whereClause); IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); @@ -428,20 +444,28 @@ private async Task GetRightResourcesWhereIdInSet_DynamicCall(HasManyTh return result.Cast().ToArray(); } - private async Task GetRightResourcesWhereIdInSet_QueryBuilderCall(HasManyThroughAttribute relationship, ISet secondaryResourceIds) + private async Task GetFilteredThroughEntities_QueryBuilderCall(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) { - var targetAttributes = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty, PublicName = relationship.RightIdProperty.Name }); - var ids = secondaryResourceIds.Select(r => new LiteralConstantExpression(r.StringId)).ToList().AsReadOnly(); - var equalsAnyOf = new EqualsAnyOfExpression(targetAttributes, ids); + var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); + var comparisionId = new LiteralConstantExpression(leftIdFilter.ToString()); + FilterExpression filter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); + if (rightIdFilter != null) + { + var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); + var equalsAnyOfIds = rightIdFilter.Select(r => new LiteralConstantExpression(r.StringId)).ToArray(); + var equalsAnyOf = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); + filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { filter, equalsAnyOf } ); + } + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); var scope = scopeFactory.CreateScope(relationship.ThroughType); var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); - var whereClause = whereClauseBuilder.ApplyWhere(equalsAnyOf); - + var whereClause = whereClauseBuilder.ApplyWhere(filter); + dynamic query = throughSource.Provider.CreateQuery(whereClause); IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); @@ -456,21 +480,27 @@ private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity, return rightResource; } - private MethodCallExpression GetContainsCall(HasManyThroughAttribute relationship, - ISet secondaryResourceIds, ParameterExpression throughEntityParameter) + private MethodCallExpression GetContainsCall(HasManyThroughAttribute relationship, ParameterExpression throughEntityParameter, ISet secondaryResourceIds) { - - var tagIdProperty = Expression.Property(throughEntityParameter, relationship.RightIdProperty.Name); + var rightIdProperty = Expression.Property(throughEntityParameter, relationship.RightIdProperty.Name); var intType = relationship.RightIdProperty.PropertyType; var typedIds = TypeHelper.CopyToList(secondaryResourceIds.Select(r => r.GetTypedId()), intType); var idCollectionConstant = Expression.Constant(typedIds); var containsCall = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] {intType}, - idCollectionConstant, tagIdProperty); + idCollectionConstant, rightIdProperty); return containsCall; } + + private BinaryExpression GetEqualsCall(HasManyThroughAttribute relationship, ParameterExpression throughEntityParameter, TId primaryResourceId) + { + var leftIdProperty = Expression.Property(throughEntityParameter, relationship.LeftIdProperty.Name); + var idConstant = Expression.Constant(primaryResourceId, typeof(TId)); + + return Expression.Equal(leftIdProperty, idConstant); + } private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) { @@ -478,10 +508,6 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute switch (relationship) { - // case HasManyThroughAttribute hasManyThroughRelationship: - // { - // return entityEntry.Collection(hasManyThroughRelationship.ThroughProperty.Name); - // } case HasManyAttribute hasManyRelationship: { return entityEntry.Collection(hasManyRelationship.Property.Name); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 2a1902e3f7..5ac9bf41b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -595,20 +595,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_HasManyThrough_relationship_with_already_attached_resource() { // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.WorkItemTags = new[] + var workItem = _fakers.WorkItem.Generate(); + workItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var differentWorkItem = _fakers.WorkItem.Generate(); + differentWorkItem.WorkItemTags = new[] { new WorkItemTag { Tag = _fakers.WorkTags.Generate() } }; - - var existingTag = _fakers.WorkTags.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingWorkItem, existingTag); + dbContext.AddRange(workItem, differentWorkItem); await dbContext.SaveChangesAsync(); }); @@ -619,17 +626,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "workTags", - id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + id = workItem.WorkItemTags.ElementAt(0).Tag.StringId }, new { type = "workTags", - id = existingTag.StringId + id = differentWorkItem.WorkItemTags.ElementAt(0).Tag.StringId } } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + var route = $"/workItems/{workItem.StringId}/relationships/tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -642,13 +649,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + .Include(wi => wi.WorkItemTags) + .ThenInclude(wit => wit.Tag) + .FirstAsync(wi => wi.Id == workItem.Id); workItemInDatabase.WorkItemTags.Should().HaveCount(2); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == workItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == differentWorkItem.WorkItemTags.ElementAt(0).Tag.Id); }); } From 00258f2696374c968f02c22d2c3aae9e4d0bbf11 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 12:24:47 +0100 Subject: [PATCH 148/240] renames --- .../Updating/Relationships/AddToToManyRelationshipTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 2a1902e3f7..7f2002bb77 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -532,7 +532,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_to_HasMany_relationship_with_already_attached_resources() + public async Task Can_add_to_HasMany_relationship_with_already_assigned_resources() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -592,7 +592,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_to_HasManyThrough_relationship_with_already_attached_resource() + public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resource() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); From 39fa1011c6a7212c619f62c07885ef0b536a86a6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 12:31:47 +0100 Subject: [PATCH 149/240] Revert: commented-out code. This is needed to guard internal consistency. Parsers prevent this exception from being thrown, but you can hit it from a ResourceDefinition override. This guard is similar to the one in LogicalExpression, it was simply missing here. --- .../Queries/Expressions/EqualsAnyOfExpression.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs index 9c50f2877a..134d8cebdd 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -20,11 +20,10 @@ public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); Constants = constants ?? throw new ArgumentNullException(nameof(constants)); - // TODO: Why is this necessary? Removing this does not lead to failing tests - // if (constants.Count < 2) - // { - // throw new ArgumentException("At least two constants are required.", nameof(constants)); - // } + if (constants.Count < 2) + { + throw new ArgumentException("At least two constants are required.", nameof(constants)); + } } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) From e8db7fe865f44116af98e92b595059de0e23bc08 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 13:21:26 +0100 Subject: [PATCH 150/240] updated test --- .../AddToToManyRelationshipTests.cs | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index cd11c34002..55c2e74c47 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -595,17 +595,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resource() { // Arrange - var workItem = _fakers.WorkItem.Generate(); - workItem.WorkItemTags = new[] + var existingWorkItems = _fakers.WorkItem.Generate(2); + existingWorkItems[0].WorkItemTags = new[] { new WorkItemTag { Tag = _fakers.WorkTags.Generate() } }; - - var differentWorkItem = _fakers.WorkItem.Generate(); - differentWorkItem.WorkItemTags = new[] + existingWorkItems[1].WorkItemTags = new[] { new WorkItemTag { @@ -615,7 +613,7 @@ public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_r await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(workItem, differentWorkItem); + dbContext.WorkItems.AddRange(existingWorkItems); await dbContext.SaveChangesAsync(); }); @@ -626,17 +624,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "workTags", - id = workItem.WorkItemTags.ElementAt(0).Tag.StringId + id = existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.StringId }, new { type = "workTags", - id = differentWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + id = existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.StringId } } }; - var route = $"/workItems/{workItem.StringId}/relationships/tags"; + var route = $"/workItems/{existingWorkItems[0].StringId}/relationships/tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -648,14 +646,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemInDatabase = await dbContext.WorkItems - .Include(wi => wi.WorkItemTags) - .ThenInclude(wit => wit.Tag) - .FirstAsync(wi => wi.Id == workItem.Id); + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .ToListAsync(); + + var workItemInDatabase1 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[0].Id); + + workItemInDatabase1.WorkItemTags.Should().HaveCount(2); + workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); + + var workItemInDatabase2 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[1].Id); - workItemInDatabase.WorkItemTags.Should().HaveCount(2); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == workItem.WorkItemTags.ElementAt(0).Tag.Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == differentWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase2.WorkItemTags.Should().HaveCount(1); + workItemInDatabase2.WorkItemTags.ElementAt(0).Tag.Id.Should().Be(existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); }); } From d68e584f35b9789e71f232ab792b1d779ef8d648 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 13:27:42 +0100 Subject: [PATCH 151/240] Clarified documentation that PATCH replaces relationships. --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 3 ++- src/JsonApiDotNetCore/Services/IUpdateService.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index f69682d071..6d3ae7d806 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -198,7 +198,8 @@ public virtual async Task PostRelationshipAsync(TId id, string re } /// - /// Updates an existing resource with attributes, relationships or both. May contain a partial set of attributes. + /// Updates the attributes and/or relationships of an existing resource. + /// Only the values of sent attributes are replaced. And only the values of sent relationships are replaced. /// Example: PATCH /articles/1 HTTP/1.1 /// public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index 32003f565f..1e2b60832f 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -13,7 +13,8 @@ public interface IUpdateService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to update an existing resource with attributes, relationships or both. May contain a partial set of attributes. + /// Handles a json:api request to update the attributes and/or relationships of an existing resource. + /// Only the values of sent attributes are replaced. And only the values of sent relationships are replaced. /// Task UpdateAsync(TId id, TResource resource); } From d4787e90f9496925b4954fb0c18c62d01106e24e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 14:11:19 +0100 Subject: [PATCH 152/240] added tests for missing cases --- .../ReplaceToManyRelationshipTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 8eee13da1f..2ec078db98 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -212,6 +212,134 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingTags = _fakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + [Fact] public async Task Cannot_replace_for_missing_type() { From 6d9ebff548068859aaec546f0db637b12f5d383f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 14:15:13 +0100 Subject: [PATCH 153/240] changed tests order for consistency --- .../AddToToManyRelationshipTests.cs | 266 +++++++++--------- .../RemoveFromToManyRelationshipTests.cs | 246 ++++++++-------- 2 files changed, 256 insertions(+), 256 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 55c2e74c47..0c60c08238 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -171,6 +171,139 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_add_to_HasMany_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resource() + { + // Arrange + var existingWorkItems = _fakers.WorkItem.Generate(2); + existingWorkItems[0].WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + existingWorkItems[1].WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.AddRange(existingWorkItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItems[0].StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .ToListAsync(); + + var workItemInDatabase1 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[0].Id); + + workItemInDatabase1.WorkItemTags.Should().HaveCount(2); + workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); + + var workItemInDatabase2 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[1].Id); + + workItemInDatabase2.WorkItemTags.Should().HaveCount(1); + workItemInDatabase2.WorkItemTags.ElementAt(0).Tag.Id.Should().Be(existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); + }); + } + [Fact] public async Task Cannot_add_for_missing_type() { @@ -531,139 +664,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in POST request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); } - [Fact] - public async Task Can_add_to_HasMany_relationship_with_already_assigned_resources() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - }, - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(1).StringId - }, - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.Subscribers.Should().HaveCount(3); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); - }); - } - - [Fact] - public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resource() - { - // Arrange - var existingWorkItems = _fakers.WorkItem.Generate(2); - existingWorkItems[0].WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - existingWorkItems[1].WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.AddRange(existingWorkItems); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "workTags", - id = existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.StringId - }, - new - { - type = "workTags", - id = existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.StringId - } - } - }; - - var route = $"/workItems/{existingWorkItems[0].StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemsInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .ToListAsync(); - - var workItemInDatabase1 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[0].Id); - - workItemInDatabase1.WorkItemTags.Should().HaveCount(2); - workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.Id); - workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); - - var workItemInDatabase2 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[1].Id); - - workItemInDatabase2.WorkItemTags.Should().HaveCount(1); - workItemInDatabase2.WorkItemTags.ElementAt(0).Tag.Id.Should().Be(existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); - }); - } - [Fact] public async Task Can_add_with_duplicates() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 7653f233d5..340cf2681d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -178,6 +178,129 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_remove_from_HasMany_relationship_with_unrelated_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + userAccountsInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_remove_from_HasManyThrough_relationship_with_unrelated_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + var existingTag = _fakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingWorkItem, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId + }, + new + { + type = "workTags", + id = existingTag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + + var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); + tagsInDatabase.Should().HaveCount(3); + }); + } + [Fact] public async Task Cannot_remove_for_missing_type() { @@ -539,129 +662,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in DELETE request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); } - [Fact] - public async Task Can_remove_from_HasMany_relationship_with_unrelated_existing_resource() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingWorkItem, existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - }, - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); - - var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); - userAccountsInDatabase.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_remove_from_HasManyThrough_relationship_with_unrelated_existing_resource() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - }, - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - var existingTag = _fakers.WorkTags.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingWorkItem, existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "workTags", - id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId - }, - new - { - type = "workTags", - id = existingTag.StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.WorkItemTags.Should().HaveCount(1); - workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); - - var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); - tagsInDatabase.Should().HaveCount(3); - }); - } - [Fact] public async Task Can_remove_with_duplicates() { From b14225814533d244d85cdeb8124a081c74b46043 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 16:10:58 +0100 Subject: [PATCH 154/240] Added tests for updating to-one relationships through primary endpoint --- .../Serialization/BaseDeserializer.cs | 45 +- .../Spec/UpdatingRelationshipsTests.cs | 236 -------- .../CreateResourceWithRelationshipTests.cs | 8 +- .../AddToToManyRelationshipTests.cs | 3 +- .../RemoveFromToManyRelationshipTests.cs | 7 +- .../ReplaceToManyRelationshipTests.cs | 3 +- .../UpdateToOneRelationshipTests.cs | 7 +- .../Resources/UpdateToOneRelationshipTests.cs | 546 ++++++++++++++++++ 8 files changed, 584 insertions(+), 271 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index f8a7f84498..f6ae17cf9b 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -155,13 +155,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) { AssertHasType(data, null); - var resourceContext = ResourceContextProvider.GetResourceContext(data.Type); - if (resourceContext == null) - { - throw new InvalidRequestBodyException("Request body includes unknown resource type.", - $"Resource of type '{data.Type}' does not exist.", null); - } - + var resourceContext = GetExistingResourceContext(data.Type); var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); @@ -173,6 +167,18 @@ private IIdentifiable ParseResourceObject(ResourceObject data) return resource; } + private ResourceContext GetExistingResourceContext(string publicName) + { + var resourceContext = ResourceContextProvider.GetResourceContext(publicName); + if (resourceContext == null) + { + throw new InvalidRequestBodyException("Request body includes unknown resource type.", + $"Resource of type '{publicName}' does not exist.", null); + } + + return resourceContext; + } + /// /// Sets a HasOne relationship on a parsed resource. If present, also /// populates the foreign key. @@ -185,15 +191,16 @@ private void SetHasOneRelationship(IIdentifiable resource, var rio = (ResourceIdentifierObject)relationshipData.Data; var relatedId = rio?.Id; + Type relationshipType = hasOneRelationship.RightType; + if (relationshipData.SingleData != null) { AssertHasType(relationshipData.SingleData, hasOneRelationship); AssertHasId(relationshipData.SingleData, hasOneRelationship); - } - var relationshipType = relationshipData.SingleData == null - ? hasOneRelationship.RightType - : ResourceContextProvider.GetResourceContext(relationshipData.SingleData.Type).ResourceType; + var resourceContext = GetExistingResourceContext(relationshipData.SingleData.Type); + relationshipType = resourceContext.ResourceType; + } // TODO: this does not make sense in the following case: if we're setting the dependent of a one-to-one relationship, IdentifiablePropertyName should be null. var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == hasOneRelationship.IdentifiablePropertyName); @@ -263,11 +270,11 @@ private void SetHasManyRelationship( // If the relationship data is null, there is no need to set the navigation property to null: this is the default value. if (relationshipData.ManyData != null) { - var relatedResources = relationshipData.ManyData + var rightResources = relationshipData.ManyData .Select(rio => CreateRightResourceForHasMany(hasManyRelationship, rio)) .ToHashSet(IdentifiableComparer.Instance); - var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, hasManyRelationship.Property.PropertyType); + var convertedCollection = TypeHelper.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); hasManyRelationship.SetValue(resource, convertedCollection, ResourceFactory); } @@ -279,11 +286,11 @@ private IIdentifiable CreateRightResourceForHasMany(HasManyAttribute hasManyRela AssertHasType(rio, hasManyRelationship); AssertHasId(rio, hasManyRelationship); - var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType; - var relatedInstance = ResourceFactory.CreateInstance(relationshipType); - relatedInstance.StringId = rio.Id; + var resourceContext = GetExistingResourceContext(rio.Type); + var rightInstance = ResourceFactory.CreateInstance(resourceContext.ResourceType); + rightInstance.StringId = rio.Id; - return relatedInstance; + return rightInstance; } private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) @@ -291,7 +298,7 @@ private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, Re if (resourceIdentifierObject.Type == null) { var details = relationship != null - ? $"Expected 'type' element in relationship '{relationship.PublicName}'." + ? $"Expected 'type' element in '{relationship.PublicName}' relationship." : "Expected 'type' element in 'data' element."; throw new InvalidRequestBodyException("Request body must include 'type' element.", details, null); @@ -303,7 +310,7 @@ private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, Rela if (resourceIdentifierObject.Id == null) { throw new InvalidRequestBodyException("Request body must include 'id' element.", - $"Expected 'id' element in relationship '{relationship.PublicName}'.", null); + $"Expected 'id' element in '{relationship.PublicName}' relationship.", null); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 50f3ab29a8..8cbf275092 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -350,105 +350,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - id = todoItem.StringId, - type = "todoItems", - relationships = new - { - owner = new - { - data = (object) null - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.Owner) - .FirstAsync(item => item.Id == todoItem.Id); - - todoItemInDatabase.Owner.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_Set_ToOne_Relationship_By_Patching_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(todoItem, person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - id = todoItem.StringId, - type = "todoItems", - relationships = new - { - owner = new - { - data = new - { - type = "people", - id = person.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.Owner) - .FirstAsync(item => item.Id == todoItem.Id); - - todoItemInDatabase.Owner.Should().NotBeNull(); - todoItemInDatabase.Owner.Id.Should().Be(person.Id); - }); - } - [Fact] public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() { @@ -500,142 +401,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => personInDatabase.TodoItems.Should().BeEmpty(); }); } - - [Fact] - public async Task Updating_OneToOne_Relationship_With_Implicit_Remove() - { - // Arrange - var person1 = _personFaker.Generate(); - var person2 = _personFaker.Generate(); - - Passport passport = null; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - - passport = new Passport(dbContext); - person1.Passport = passport; - - dbContext.People.AddRange(person1, person2); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person2.StringId, - relationships = new Dictionary - { - ["passport"] = new - { - data = new - { - type = "passports", - id = passport.StringId - } - } - } - } - }; - - var route = "/api/v1/people/" + person2.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personsInDatabase = await dbContext.People - .Include(person => person.Passport) - .ToListAsync(); - - personsInDatabase.Single(person => person.Id == person1.Id).Passport.Should().BeNull(); - personsInDatabase.Single(person => person.Id == person2.Id).Passport.Id.Should().Be(passport.Id); - }); - } - - [Fact] - public async Task Fails_When_Patching_On_Primary_Endpoint_With_Missing_Secondary_Resources() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(todoItem, person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - relationships = new Dictionary - { - ["stakeHolders"] = new - { - data = new[] - { - new - { - type = "people", - id = person.StringId - }, - new - { - type = "people", - id = "900000" - }, - new - { - type = "people", - id = "900001" - } - } - }, - ["parentTodo"] = new - { - data = new - { - type = "todoItems", - id = "900002" - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(3); - - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'people' with ID '900000' being assigned to relationship 'stakeHolders' does not exist."); - - responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[1].Detail.Should().Be("Resource of type 'people' with ID '900001' being assigned to relationship 'stakeHolders' does not exist."); - - responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[2].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[2].Detail.Should().Be("Resource of type 'todoItems' with ID '900002' being assigned to relationship 'parentTodo' does not exist."); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 8524c82fc0..d8a85332bd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -315,7 +315,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in relationship 'assignedTo'. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignedTo' relationship. - Request body: <<"); } [Fact] @@ -351,7 +351,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in relationship 'assignedTo'. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignedTo' relationship. - Request body: <<"); } [Fact] @@ -845,7 +845,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in relationship 'subscribers'. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); } [Fact] @@ -884,7 +884,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in relationship 'subscribers'. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 0c60c08238..5502703a63 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -552,12 +552,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_to_unknown_resource_ID_in_url() { // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingWorkItem, existingSubscriber); + dbContext.UserAccounts.Add(existingSubscriber); await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 340cf2681d..7cb528f994 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -550,12 +550,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_unknown_resource_ID_in_url() { // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.WorkItems.Add(existingWorkItem); + dbContext.UserAccounts.Add(existingSubscriber); await dbContext.SaveChangesAsync(); }); @@ -566,7 +565,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId + id = existingSubscriber.StringId } } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 2ec078db98..61e3752d24 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -588,12 +588,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_replace_on_unknown_resource_ID_in_url() { // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingWorkItem, existingSubscriber); + dbContext.UserAccounts.Add(existingSubscriber); await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index 5b61bf4848..1da6cbf8bb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -346,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "userAccounts", - id = 88888888 + id = 99999999 } }; @@ -362,7 +362,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'assignedTo' does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); } [Fact] @@ -402,12 +402,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_on_unknown_resource_ID_in_url() { // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); var existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingWorkItem, existingUserAccount); + dbContext.UserAccounts.Add(existingUserAccount); await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..9faf6063ee --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -0,0 +1,546 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +{ + public sealed class UpdateToOneRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateToOneRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_HasOne_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignedTo = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.AssignedTo.Should().BeNull(); + + var userAccountInDatabase = await dbContext.UserAccounts + .Include(userAccount => userAccount.AssignedItems) + .FirstOrDefaultAsync(userAccount => userAccount.Id == existingWorkItem.AssignedTo.Id); + + userAccountInDatabase.Should().NotBeNull(); + userAccountInDatabase.AssignedItems.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingGroup, existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId, + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId + } + } + } + } + }; + + var route = "/workItemGroups/" + existingGroup.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); + colorInDatabase1.Group.Should().BeNull(); + + var colorInDatabase2 = colorsInDatabase.Single(p => p.Id == existingColor.Id); + colorInDatabase2.Group.Should().NotBeNull(); + colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingGroups = _fakers.WorkItemGroup.Generate(2); + existingGroups[0].Color = _fakers.RgbColor.Generate(); + existingGroups[1].Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroups); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingGroups[0].Color.StringId, + relationships = new + { + group = new + { + data = new + { + type = "workItemGroups", + id = existingGroups[1].StringId + } + } + } + } + }; + + var route = "/rgbColors/" + existingGroups[0].Color.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var groupInDatabase1 = groupsInDatabase.Single(p => p.Id == existingGroups[0].Id); + groupInDatabase1.Color.Should().BeNull(); + + var groupInDatabase2 = groupsInDatabase.Single(p => p.Id == existingGroups[1].Id); + groupInDatabase2.Color.Should().NotBeNull(); + groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); + + var colorsInDatabase = await dbContext.RgbColors + .Include(color => color.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color.Id); + colorInDatabase1.Group.Should().NotBeNull(); + colorInDatabase1.Group.Id.Should().Be(existingGroups[1].Id); + + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingGroups[1].Color.Id); + colorInDatabase2.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + existingUserAccounts[0].AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingUserAccounts[0].AssignedItems.ElementAt(1).StringId, + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + }; + + var route = "/workItems/" + existingUserAccounts[0].AssignedItems.ElementAt(1).StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.AssignedTo) + .ToListAsync(); + + var workItemInDatabase2 = workItemsInDatabase.Single(p => p.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); + workItemInDatabase2.AssignedTo.Should().NotBeNull(); + workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); + + var userAccountsInDatabase = await dbContext.UserAccounts + .Include(userAccount => userAccount.AssignedItems) + .ToListAsync(); + + var userAccountInDatabase1 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[0].Id); + userAccountInDatabase1.AssignedItems.Should().HaveCount(1); + userAccountInDatabase1.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(0).Id); + + var userAccountInDatabase2 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[1].Id); + userAccountInDatabase2.AssignedItems.Should().HaveCount(1); + userAccountInDatabase2.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignedTo = new + { + data = new + { + id = 99999999 + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignedTo' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignedTo = new + { + data = new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts" + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignedTo' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_unknown_relationship_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = 99999999 + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/doesNotExist/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_create_on_unknown_resource_ID_in_url() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = 99999999, + relationships = new + { + assignedTo = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + } +} From 075b08131fb8ec2b40ef1b5ee30033cabdef896c Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 27 Oct 2020 16:42:44 +0100 Subject: [PATCH 155/240] chore: review, remove resource factory, todos --- .../JsonApiDotNetCoreExample/appsettings.json | 2 +- .../Hooks/Internal/ResourceHookExecutor.cs | 12 +-- .../Hooks/Internal/Traversal/ChildNode.cs | 6 +- .../Hooks/Internal/Traversal/IResourceNode.cs | 2 +- .../Internal/Traversal/RelationshipProxy.cs | 4 +- .../Hooks/Internal/Traversal/RootNode.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 90 ++++++++++--------- .../Annotations/HasManyThroughAttribute.cs | 9 +- .../Resources/Annotations/HasOneAttribute.cs | 3 +- .../Annotations/RelationshipAttribute.cs | 3 +- .../Serialization/BaseDeserializer.cs | 6 +- .../Client/Internal/ResponseDeserializer.cs | 4 +- 12 files changed, 72 insertions(+), 71 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 2b56699951..866a5a6b6f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -6,7 +6,7 @@ "LogLevel": { "Default": "Warning", "Microsoft": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "AllowedHosts": "*" diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index 360b469093..0cba0e41c6 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -70,7 +70,7 @@ public IEnumerable BeforeUpdate(IEnumerable res var diff = new DiffableResourceHashSet(node.UniqueResources, dbValues, node.LeftsToNextLayer(), _targetedFields); IEnumerable updated = container.BeforeUpdate(diff, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); @@ -85,7 +85,7 @@ public IEnumerable BeforeCreate(IEnumerable res var affected = new ResourceHashSet((HashSet)node.UniqueResources, node.LeftsToNextLayer()); IEnumerable updated = container.BeforeCreate(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); return resources; @@ -102,7 +102,7 @@ public IEnumerable BeforeDelete(IEnumerable res IEnumerable updated = container.BeforeDelete(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } // If we're deleting an article, we're implicitly affected any owners related to it. @@ -126,14 +126,14 @@ public IEnumerable OnReturn(IEnumerable resourc IEnumerable updated = container.OnReturn((HashSet)node.UniqueResources, pipeline); ValidateHookResponse(updated); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } Traverse(_traversalHelper.CreateNextLayer(node), ResourceHook.OnReturn, (nextContainer, nextNode) => { var filteredUniqueSet = CallHook(nextContainer, ResourceHook.OnReturn, new object[] { nextNode.UniqueResources, pipeline }); nextNode.UpdateUnique(filteredUniqueSet); - nextNode.Reassign(_resourceFactory); + nextNode.Reassign(); }); return resources; } @@ -283,7 +283,7 @@ private void FireNestedBeforeUpdateHooks(ResourcePipeline pipeline, NodeLayer la var allowedIds = CallHook(nestedHookContainer, ResourceHook.BeforeUpdateRelationship, new object[] { GetIds(uniqueResources), resourcesByRelationship, pipeline }).Cast(); var updated = GetAllowedResources(uniqueResources, allowedIds); node.UpdateUnique(updated); - node.Reassign(_resourceFactory); + node.Reassign(); } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs index 49df8d702c..f35540e7d4 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs @@ -51,7 +51,7 @@ public void UpdateUnique(IEnumerable updated) /// /// Reassignment is done according to provided relationships /// - public void Reassign(IResourceFactory resourceFactory, IEnumerable updated = null) + public void Reassign(IEnumerable updated = null) { var unique = (HashSet)UniqueResources; foreach (var group in _relationshipsFromPreviousLayer) @@ -67,13 +67,13 @@ public void Reassign(IResourceFactory resourceFactory, IEnumerable updated = nul { var intersection = relationshipCollection.Intersect(unique, _comparer); IEnumerable typedCollection = TypeHelper.CopyToTypedCollection(intersection, relationshipCollection.GetType()); - proxy.SetValue(left, typedCollection, resourceFactory); + proxy.SetValue(left, typedCollection); } else if (currentValue is IIdentifiable relationshipSingle) { if (!unique.Intersect(new HashSet { relationshipSingle }, _comparer).Any()) { - proxy.SetValue(left, null, resourceFactory); + proxy.SetValue(left, null); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs index 5ae502336c..7f687598fc 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs @@ -31,7 +31,7 @@ internal interface IResourceNode /// A helper method to assign relationships to the previous layer after firing hooks. /// Or, in case of the root node, to update the original source enumerable. /// - void Reassign(IResourceFactory resourceFactory, IEnumerable source = null); + void Reassign(IEnumerable source = null); /// /// A helper method to internally update the unique set of resources as a result of /// a filter action in a hook. diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs index ab701882e5..1f9cfafa93 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs @@ -82,7 +82,7 @@ public object GetValue(IIdentifiable resource) /// Parent resource. /// The relationship value. /// - public void SetValue(IIdentifiable resource, object value, IResourceFactory resourceFactory) + public void SetValue(IIdentifiable resource, object value) { if (Attribute is HasManyThroughAttribute hasManyThrough) { @@ -109,7 +109,7 @@ public void SetValue(IIdentifiable resource, object value, IResourceFactory reso return; } - Attribute.SetValue(resource, value, resourceFactory); + Attribute.SetValue(resource, value); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs index 3c3103fd52..99a3057841 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs @@ -59,7 +59,7 @@ public void UpdateUnique(IEnumerable updated) _uniqueResources = new HashSet(intersected); } - public void Reassign(IResourceFactory resourceFactory, IEnumerable source = null) + public void Reassign(IEnumerable source = null) { var ids = _uniqueResources.Select(ue => ue.StringId); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7588700db6..4b11264a90 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -5,6 +5,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Threading.Tasks; +using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -306,7 +307,7 @@ private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, T PrepareChangeTrackerForNullAssignment(relationship, leftResource); } - relationship.SetValue(leftResource, trackedValueToAssign, _resourceFactory); + relationship.SetValue(leftResource, trackedValueToAssign); } private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) @@ -352,7 +353,7 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi { if (relationship is HasManyThroughAttribute hasManyThroughRelationship) { - var throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, resource.Id, null); + var throughEntities = await GetFilteredThroughEntities_StaticQueryBuilding(hasManyThroughRelationship, resource.Id, null); hasManyThroughRelationship.ThroughProperty.SetValue(resource, TypeHelper.CopyToTypedCollection(throughEntities, hasManyThroughRelationship.ThroughProperty.PropertyType)); foreach (var throughEntity in throughEntities) @@ -364,6 +365,14 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi else { var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); + // TODO: Figure out how we can trick EF Core into thinking that the relationship is fully loaded such that + // FKs are nulled (as required for complete replacement). + // var dummy = _resourceFactory.CreateInstance(relationship.RightType); + // dummy.StringId = "999"; + // _dbContext.Entry(dummy).State = EntityState.Unchanged; + // var list = new[] {dummy}; + // relationship.SetValue(resource, TypeHelper.CopyToTypedCollection(list, relationship.Property.PropertyType)); + // navigationEntry.IsLoaded = true; await navigationEntry.LoadAsync(); } } @@ -378,57 +387,52 @@ private void FlushFromCache(IIdentifiable resource) private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, TId primaryResourceId, ISet secondaryResourceIds) { object[] throughEntities; - - throughEntities = await GetFilteredThroughEntities_GenericCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - - throughEntities = await GetFilteredThroughEntities_DynamicCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - - throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - + throughEntities = await GetFilteredThroughEntities_StaticQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + + // throughEntities = await GetFilteredThroughEntities_DynamicQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + // throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + var rightResources = throughEntities.Select(entity => ConstructRightResourceOfHasManyRelationship(entity, hasManyThroughRelationship)).ToHashSet(); secondaryResourceIds.ExceptWith(rightResources); Detach(throughEntities); } - private async Task GetFilteredThroughEntities_GenericCall(HasManyThroughAttribute hasManyThroughRelationship, TId leftIdFilter, ISet rightIdFilter) + private async Task GetFilteredThroughEntities_StaticQueryBuilding(HasManyThroughAttribute hasManyThroughRelationship, TId leftIdFilter, ISet rightIdFilter) { - var openGenericCallMethod = GetType().GetMethod(nameof(GetFilteredThroughEntities_GenericCall)); - if (openGenericCallMethod != null) - { - var genericCallMethod = openGenericCallMethod.MakeGenericMethod(hasManyThroughRelationship.ThroughType); - var task = genericCallMethod.Invoke(this, new object[] {hasManyThroughRelationship, leftIdFilter, rightIdFilter}); - if (task != null) - { - return await (dynamic) task; - } - } - - throw new InvalidOperationException(); + dynamic dummyInstance = Activator.CreateInstance(hasManyThroughRelationship.ThroughType); + return await ((dynamic)this).GetFilteredThroughEntities_StaticQueryBuilding(dummyInstance, hasManyThroughRelationship, leftIdFilter, rightIdFilter); } - public async Task GetFilteredThroughEntities_GenericCall(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) where TThroughType : class + public async Task GetFilteredThroughEntities_StaticQueryBuilding(TThroughType _, HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) where TThroughType : class { - var namingFactory = new LambdaParameterNameFactory(); - var throughEntityParameter = Expression.Parameter(relationship.ThroughType, namingFactory.Create(relationship.ThroughType.Name).Name); + var filter = GetThroughEntityFilterExpression(relationship, leftIdFilter, rightIdFilter); - var containsCall = GetContainsCall(relationship, throughEntityParameter, rightIdFilter); - var equalsCall = GetEqualsCall(relationship, throughEntityParameter, leftIdFilter); - var conjunction = Expression.AndAlso(equalsCall, containsCall); + var result = await _dbContext.Set().Where(filter).ToListAsync(); - var predicate = Expression.Lambda>(conjunction, throughEntityParameter); - var result = await _dbContext.Set().Where(predicate).ToListAsync(); - return result.Cast().ToArray(); } - - private async Task GetFilteredThroughEntities_DynamicCall(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) + + private Expression> GetThroughEntityFilterExpression(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) where TThroughType : class { - var namingFactory = new LambdaParameterNameFactory(); - var parameterName = namingFactory.Create(relationship.ThroughType.Name).Name; - var throughEntityParameter = Expression.Parameter(relationship.ThroughType, parameterName); - + var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); + + Expression filter = GetEqualsCall(relationship, throughEntityParameter, leftIdFilter); + + if (rightIdFilter != null) + { + var containsCall = GetContainsCall(relationship, throughEntityParameter, rightIdFilter); + filter = Expression.AndAlso(filter, containsCall); + } + + return Expression.Lambda>(filter, throughEntityParameter); + } + + private async Task GetFilteredThroughEntities_DynamicQueryBuilding(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) + { + var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); + var containsCall = GetContainsCall(relationship, throughEntityParameter, secondaryResourceIds) ; var equalsCall = GetEqualsCall(relationship, throughEntityParameter, primaryResourceId); var conjunction = Expression.AndAlso(equalsCall, containsCall); @@ -446,7 +450,7 @@ private async Task GetFilteredThroughEntities_DynamicCall(HasManyThrou private async Task GetFilteredThroughEntities_QueryBuilderCall(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) { - var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); + var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); var comparisionId = new LiteralConstantExpression(leftIdFilter.ToString()); FilterExpression filter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); @@ -484,11 +488,11 @@ private MethodCallExpression GetContainsCall(HasManyThroughAttribute relationshi { var rightIdProperty = Expression.Property(throughEntityParameter, relationship.RightIdProperty.Name); - var intType = relationship.RightIdProperty.PropertyType; - var typedIds = TypeHelper.CopyToList(secondaryResourceIds.Select(r => r.GetTypedId()), intType); + var idType = relationship.RightIdProperty.PropertyType; + var typedIds = TypeHelper.CopyToList(secondaryResourceIds.Select(r => r.GetTypedId()), idType); var idCollectionConstant = Expression.Constant(typedIds); - var containsCall = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] {intType}, + var containsCall = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] {idType}, idCollectionConstant, rightIdProperty); return containsCall; @@ -510,6 +514,8 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute { case HasManyAttribute hasManyRelationship: { + // TODO: See if we can get around this by fiddling around with "IsLoaded"? Does EF Core execute one query to delete when saving a complete replacement? + // Consider clearing the entire relationship first rather than fetching it and then letting EF do it inefficiently. return entityEntry.Collection(hasManyRelationship.Property.Name); } case HasOneAttribute hasOneRelationship: @@ -556,7 +562,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio // This fails when that entity has null reference(s) for its primary key(s). EnsurePrimaryKeyPropertiesAreNotNull(placeholderRightResource); - relationship.SetValue(leftResource, placeholderRightResource, _resourceFactory); + relationship.SetValue(leftResource, placeholderRightResource); _dbContext.Entry(leftResource).DetectChanges(); Detach(placeholderRightResource); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 75e9628c5e..41a4e44eb5 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -121,12 +121,11 @@ public override object GetValue(object resource) /// Traverses through the provided resource and sets the value of the relationship on the other side of the through type. /// In the example described above, this would be the value of "Articles.ArticleTags.Tag". /// - public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) // TODO: delete resource factory: is this possible? + public override void SetValue(object resource, object newValue) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); - base.SetValue(resource, newValue, resourceFactory); + base.SetValue(resource, newValue); if (newValue == null) { @@ -137,9 +136,7 @@ public override void SetValue(object resource, object newValue, IResourceFactory List throughResources = new List(); foreach (IIdentifiable identifiable in (IEnumerable)newValue) { - var throughResource = TypeHelper.IsOrImplementsInterface(ThroughType, typeof(IIdentifiable)) - ? resourceFactory.CreateInstance(ThroughType) - : TypeHelper.CreateInstance(ThroughType); + var throughResource = TypeHelper.CreateInstance(ThroughType); LeftProperty.SetValue(throughResource, resource); RightProperty.SetValue(throughResource, identifiable); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index 969fd48661..a7e822a1cc 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -37,10 +37,9 @@ public HasOneAttribute() } /// - public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + public override void SetValue(object resource, object newValue) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); // TODO: Given recent changes, does the following code still need access to foreign keys, or can this be handled by the caller now? diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index fea89da90d..eeab77e715 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -98,10 +98,9 @@ public virtual object GetValue(object resource) /// /// Sets the value of the resource property this attribute was declared on. /// - public virtual void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + public virtual void SetValue(object resource, object newValue) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); Property.SetValue(resource, newValue); } diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index f8a7f84498..7944f1098f 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -242,13 +242,13 @@ private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string { if (relatedId == null) { - attr.SetValue(resource, null, ResourceFactory); + attr.SetValue(resource, null); } else { var relatedInstance = ResourceFactory.CreateInstance(relationshipType); relatedInstance.StringId = relatedId; - attr.SetValue(resource, relatedInstance, ResourceFactory); + attr.SetValue(resource, relatedInstance); } } @@ -268,7 +268,7 @@ private void SetHasManyRelationship( .ToHashSet(IdentifiableComparer.Instance); var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, hasManyRelationship.Property.PropertyType); - hasManyRelationship.SetValue(resource, convertedCollection, ResourceFactory); + hasManyRelationship.SetValue(resource, convertedCollection); } AfterProcessField(resource, hasManyRelationship, relationshipData); diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs index 59ac58d9cf..c271f65b95 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs @@ -72,13 +72,13 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA { // add attributes and relationships of a parsed HasOne relationship var rio = data.SingleData; - hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(rio), ResourceFactory); + hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(rio)); } else if (field is HasManyAttribute hasManyAttr) { // add attributes and relationships of a parsed HasMany relationship var items = data.ManyData.Select(rio => ParseIncludedRelationship(rio)); var values = TypeHelper.CopyToTypedCollection(items, hasManyAttr.Property.PropertyType); - hasManyAttr.SetValue(resource, values, ResourceFactory); + hasManyAttr.SetValue(resource, values); } } From 90368ecee1727a9a5cbea88f16a565716a8dc472 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 17:16:43 +0100 Subject: [PATCH 156/240] Added tests for updating to-many relationships through primary endpoint --- .../Annotations/HasManyThroughAttribute.cs | 10 +- .../Services/JsonApiResourceService.cs | 24 +- .../Acceptance/ManyToManyTests.cs | 132 --- .../Acceptance/Spec/UpdatingDataTests.cs | 4 +- .../Spec/UpdatingRelationshipsTests.cs | 193 ---- .../ReplaceToManyRelationshipTests.cs | 916 ++++++++++++++++++ 6 files changed, 944 insertions(+), 335 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 75e9628c5e..25f59838a9 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -108,11 +108,15 @@ public override object GetValue(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - IEnumerable throughEntities = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); + var value = ThroughProperty.GetValue(resource); + if (value == null) + { + return null; + } - IEnumerable rightResources = throughEntities + IEnumerable rightResources = ((IEnumerable) value) .Cast() - .Select(te => RightProperty.GetValue(te)); + .Select(joinEntity => RightProperty.GetValue(joinEntity)); return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 48dda82b1b..1e82fdcdc8 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -266,6 +266,12 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _traceWriter.LogMethodStart(new {id, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); + foreach (var hasManyRelationship in _targetedFields.Relationships.OfType()) + { + var rightResources = hasManyRelationship.GetValue(resource); + AssertHasManyRelationshipValueIsNotNull(rightResources); + } + var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -310,10 +316,9 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); - if (_request.Relationship is HasManyAttribute && secondaryResourceIds == null) + if (_request.Relationship is HasManyAttribute) { - // TODO: Usage of InvalidRequestBodyException (here and in BaseJsonApiController) is probably not the nest choice, because they do not contain request body. - throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", null, null); + AssertHasManyRelationshipValueIsNotNull(secondaryResourceIds); } TResource primaryResource = null; @@ -553,8 +558,7 @@ private void AssertPrimaryResourceExists(TResource resource) private void AssertRelationshipExists(string relationshipName) { - var relationship = _request.Relationship; - if (relationship == null) + if (_request.Relationship == null) { throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } @@ -569,6 +573,16 @@ private void AssertRelationshipIsToMany() } } + private void AssertHasManyRelationshipValueIsNotNull(object secondaryResourceIds) + { + if (secondaryResourceIds == null) + { + // TODO: Usage of InvalidRequestBodyException (here and in BaseJsonApiController) is probably not the nest choice, because they do not contain request body. + // We should either make it include the request body -or- throw a different exception. + throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", null, null); + } + } + private List ToList(TResource resource) { return new List { resource }; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 0aa85a33b5..6401d3735d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; @@ -7,7 +6,6 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -95,135 +93,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); responseDocument.ManyData[0].Attributes.Should().BeNull(); } - - [Fact] - public async Task Can_Set_HasManyThrough_Relationship_Through_Primary_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - var existingTag = _tagFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingArticleTag, existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "articles", - id = existingArticleTag.Article.StringId, - relationships = new - { - tags = new - { - data = new[] - { - new - { - type = "tags", - id = existingTag.StringId - } - } - } - } - } - }; - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - - articleInDatabase.ArticleTags.Should().HaveCount(1); - articleInDatabase.ArticleTags.Single().TagId.Should().Be(existingTag.Id); - }); - } - - [Fact] - public async Task Can_Set_With_Overlap_To_HasManyThrough_Relationship_Through_Primary_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - var existingTag = _tagFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingArticleTag, existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "articles", - id = existingArticleTag.Article.StringId, - relationships = new - { - tags = new - { - data = new[] - { - new - { - type = "tags", - id = existingArticleTag.Tag.StringId - }, - new - { - type = "tags", - id = existingTag.StringId - } - } - } - } - } - }; - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var articleInDatabase = await dbContext.Articles - .Include(article => article.ArticleTags) - .FirstAsync(article => article.Id == existingArticleTag.Article.Id); - - articleInDatabase.ArticleTags.Should().HaveCount(2); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingArticleTag.Tag.Id); - articleInDatabase.ArticleTags.Should().ContainSingle(articleTag => articleTag.TagId == existingTag.Id); - }); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index cdb74f61e5..5b510a46bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -503,7 +503,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = todoItem.Owner.StringId, attributes = new Dictionary { - ["firstName"] = "John", + ["firstName"] = "#John", ["lastName"] = "Doe" } } @@ -518,7 +518,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); + responseDocument.SingleData.Attributes["firstName"].Should().Be("#John"); responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 8cbf275092..f91db5755b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; @@ -209,197 +208,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => todoItemInDatabase.ParentTodoId.Should().Be(todoItem.Id); }); } - - [Fact] - public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var person1 = _personFaker.Generate(); - person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - - var person2 = _personFaker.Generate(); - person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.AddRange(person1, person2); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person2.StringId, - relationships = new Dictionary - { - ["todoItems"] = new - { - data = new[] - { - new - { - type = "todoItems", - id = person1.TodoItems.ElementAt(0).StringId - }, - new - { - type = "todoItems", - id = person1.TodoItems.ElementAt(1).StringId - } - } - } - } - } - }; - - var route = "/api/v1/people/" + person2.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personsInDatabase = await dbContext.People - .Include(person => person.TodoItems) - .ToListAsync(); - - personsInDatabase.Single(person => person.Id == person1.Id).TodoItems.Should().HaveCount(1); - - var person2InDatabase = personsInDatabase.Single(person => person.Id == person2.Id); - person2InDatabase.TodoItems.Should().HaveCount(2); - person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(0).Id); - person2InDatabase.TodoItems.Should().ContainSingle(x => x.Id == person1.TodoItems.ElementAt(1).Id); - }); - } - - [Fact] - public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overlap() - { - // Arrange - var todoItem1 = _todoItemFaker.Generate(); - var todoItem2 = _todoItemFaker.Generate(); - - var todoCollection = new TodoItemCollection - { - Owner = _personFaker.Generate(), - TodoItems = new HashSet - { - todoItem1, - todoItem2 - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItemCollections.Add(todoCollection); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoCollections", - id = todoCollection.StringId, - relationships = new Dictionary - { - ["todoItems"] = new - { - data = new[] - { - new - { - type = "todoItems", - id = todoItem1.StringId - }, - new - { - type = "todoItems", - id = todoItem2.StringId - } - } - } - } - } - }; - - var route = "/api/v1/todoCollections/" + todoCollection.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoCollectionInDatabase = await dbContext.TodoItemCollections - .Include(collection => collection.TodoItems) - .FirstAsync(collection => collection.Id == todoCollection.Id); - - todoCollectionInDatabase.TodoItems.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var person = _personFaker.Generate(); - person.TodoItems = new HashSet - { - _todoItemFaker.Generate() - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - id = person.StringId, - type = "people", - relationships = new Dictionary - { - ["todoItems"] = new - { - data = new object[0] - } - } - } - }; - - var route = "/api/v1/people/" + person.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var personInDatabase = await dbContext.People - .Include(p => p.TodoItems) - .FirstAsync(p => p.Id == person.Id); - - personInDatabase.TodoItems.Should().BeEmpty(); - }); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..bd3ce5bd29 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,916 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +{ + public sealed class ReplaceToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public ReplaceToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingTags = _fakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(2); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingTags = _fakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + + [Fact] + public async Task Cannot_replace_for_missing_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_missing_relationship_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_with_unknown_relationship_IDs_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_replace_with_unknown_relationship_IDs_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workTags' with ID '88888888' being assigned to relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_replace_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/doesNotExist/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_replace_on_unknown_resource_ID_in_url() + { + // Arrange + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = 99999999, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Can_replace_with_duplicates() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} From f6cb370c18bfd3bdc7188f07d193816825e7f1ab Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 27 Oct 2020 17:58:47 +0100 Subject: [PATCH 157/240] chore: review --- .../Hooks/Internal/ResourceHookExecutor.cs | 5 +---- .../Hooks/Internal/Traversal/RelationshipProxy.cs | 1 - .../Repositories/EntityFrameworkCoreRepository.cs | 15 +++++++++++---- .../ReplaceToManyRelationshipTests.cs | 5 +++-- .../ResourceHooks/ResourceHooksTestsSetup.cs | 9 +++------ 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index 0cba0e41c6..405634c8fd 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -23,22 +23,19 @@ internal sealed class ResourceHookExecutor : IResourceHookExecutor private readonly IEnumerable _constraintProviders; private readonly ITargetedFields _targetedFields; private readonly IResourceGraph _resourceGraph; - private readonly IResourceFactory _resourceFactory; public ResourceHookExecutor( IHookExecutorHelper executorHelper, ITraversalHelper traversalHelper, ITargetedFields targetedFields, IEnumerable constraintProviders, - IResourceGraph resourceGraph, - IResourceFactory resourceFactory) + IResourceGraph resourceGraph) { _executorHelper = executorHelper; _traversalHelper = traversalHelper; _targetedFields = targetedFields; _constraintProviders = constraintProviders; _resourceGraph = resourceGraph; - _resourceFactory = resourceFactory; } /// diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs index 1f9cfafa93..054d4c155c 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs @@ -81,7 +81,6 @@ public object GetValue(IIdentifiable resource) /// /// Parent resource. /// The relationship value. - /// public void SetValue(IIdentifiable resource, object value) { if (Attribute is HasManyThroughAttribute hasManyThrough) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 689ef4eda0..7916441db1 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -297,6 +297,10 @@ private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, T if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) { + // TODO: Similar to like the EnableCompleteReplacement todo, we dont actually need to load the inverse relationship. + // all we need to do is clear the inverse relationship such that no uniqueness constraint is violated + // (or have EF core do it efficiently, i.e without having to first fetch the data). + // For one to one it isn't much of a performance issue to because it is a ToOne relationship rather than a large collection. But it would be cleaner to not load it. var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; await entityEntry.Reference(inversePropertyName).LoadAsync(); @@ -347,14 +351,15 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi _traceWriter.LogMethodStart(new {relationship, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - - // TODO: FK Uniqueness constraints can also be violated from principal side (formerly referred to as implicit removals). Ensure that the Can_replace_OneToOne_relationship_from_principal_side test adequately covers this. + // If the left resource is the dependent side of the relationship, complete replacement is already guaranteed. if (!HasForeignKeyAtLeftSide(relationship)) { if (relationship is HasManyThroughAttribute hasManyThroughRelationship) { - // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. Figure out how to trick EF Core into doing this without having to first load all the data. + // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. + // Figure out how to trick EF Core into doing this without having to first load all the data (or do it ourselves). + // If we do it ourselves it would probably involve a writing a DeleteWhere extension method. var throughEntities = await GetFilteredThroughEntities_StaticQueryBuilding(hasManyThroughRelationship, resource.Id, null); hasManyThroughRelationship.ThroughProperty.SetValue(resource, TypeHelper.CopyToTypedCollection(throughEntities, hasManyThroughRelationship.ThroughProperty.PropertyType)); @@ -367,7 +372,9 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi else { var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); - // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. Figure out how to trick EF Core into doing this without having to first load all the data. + // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. + // Figure out how to trick EF Core into doing this without having to first load all the data (or do it ourselves). + // If we do it ourselves it would probably involve a writing a DeleteWhere extension method. // var dummy = _resourceFactory.CreateInstance(relationship.RightType); // dummy.StringId = "999"; // _dbContext.Entry(dummy).State = EntityState.Unchanged; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 61e3752d24..a485faaeea 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -34,7 +35,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = new object[0] + data = Array.Empty() }; var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; @@ -78,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = new object[0] + data = Array.Empty() }; var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 9be672f800..98f6a4b693 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -173,8 +173,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var resourceFactory = new Mock().Object; - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph); return (constraintsMock, hookExecutor, primaryResource); } @@ -207,8 +206,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var resourceFactory = new Mock().Object; - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph); return (constraintsMock, ufMock, hookExecutor, primaryResource, secondaryResource); } @@ -246,8 +244,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var resourceFactory = new Mock().Object; - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph); return (constraintsMock, hookExecutor, primaryResource, firstSecondaryResource, secondSecondaryResource); } From d850c6e8e1ec4f2a27ebccf494fe9af92bad5af6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Oct 2020 18:20:05 +0100 Subject: [PATCH 158/240] revert changes --- .../Updating/Relationships/ReplaceToManyRelationshipTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index a485faaeea..5499f2bb52 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -35,7 +35,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = Array.Empty() + data = new object[0] }; var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; @@ -79,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = Array.Empty() + data = new object[0] }; var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; From 8dd4ba3ac2014a8ff41e1120552d396d629934db Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 10:51:27 +0100 Subject: [PATCH 159/240] added missing test --- .../Writing/Creating/CreateResourceTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 21c0854f08..a3913f9c73 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -386,6 +386,32 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); } + [Fact] + public async Task Cannot_create_resource_on_unknown_resource_type_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + } + } + }; + + var route = "/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + [Fact] public async Task Cannot_create_resource_with_blocked_attribute() { From 4af1e935184fd3478e568e3d82cfdf2e13169c94 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 28 Oct 2020 11:04:00 +0100 Subject: [PATCH 160/240] chore: add todos, draft bugfix deserializer id validation --- .../Serialization/BaseDeserializer.cs | 2 +- .../Serialization/JsonApiReader.cs | 25 ++++++++------- .../Serialization/RequestDeserializer.cs | 32 ++++++++++++++++++- .../AddToToManyRelationshipTests.cs | 8 +++-- .../RemoveFromToManyRelationshipTests.cs | 3 +- .../ReplaceToManyRelationshipTests.cs | 6 +++- .../UpdateToOneRelationshipTests.cs | 31 ++++++++++++++++-- 7 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 71b24bfa68..630fba0f6d 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -202,7 +202,7 @@ private void SetHasOneRelationship(IIdentifiable resource, relationshipType = resourceContext.ResourceType; } - // TODO: this does not make sense in the following case: if we're setting the dependent of a one-to-one relationship, IdentifiablePropertyName should be null. + // TODO: this does not make sense in the following case: if we're setting the principal side of a one-to-one relationship, IdentifiablePropertyName should be null. var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == hasOneRelationship.IdentifiablePropertyName); if (foreignKeyProperty != null) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index ee06034d5e..7019ce29f0 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -4,6 +4,7 @@ using System.IO; using System.Net.Http; using System.Linq; +using System.Security.Cryptography.Xml; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -69,13 +70,14 @@ public async Task ReadAsync(InputFormatterContext context) throw new InvalidRequestBodyException(null, null, body, exception); } - ValidatePatchRequestIncludesId(context, model, body); + ValidateRequestIncludesId(context, model, body); ValidateIncomingResourceType(context, model); return await InputFormatterResult.SuccessAsync(model); } + // TODO: Consider moving these assertions to RequestDeserializer. See next todo. private void ValidateIncomingResourceType(InputFormatterContext context, object model) { if (context.HttpContext.IsJsonApiRequest() && context.HttpContext.Request.Method != HttpMethods.Get) @@ -102,16 +104,21 @@ private void ValidateIncomingResourceType(InputFormatterContext context, object } } - private void ValidatePatchRequestIncludesId(InputFormatterContext context, object model, string body) + // TODO: Consider moving these assertions to RequestDeserializer. + // Right now, BaseDeserializer is responsible for throwing errors when id/type is missing in scenarios that this is ALWAYS true, + // regardless of server/client side deserialization. The assertions below are only relevant for server deserializers since they depend on + // IJsonApiRequest and HttpContextAccessor. Right now these two are already used to check the http request method and endpoint kind, so might as well move this into there. + // Additional up side: testability improves. + private void ValidateRequestIncludesId(InputFormatterContext context, object model, string body) { - if (context.HttpContext.Request.Method == HttpMethods.Patch) + if (context.HttpContext.Request.Method == HttpMethods.Patch || _request.Kind == EndpointKind.Relationship) { - bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); + bool hasMissingId = model is IEnumerable collection ? HasMissingId(collection) : HasMissingId(model); if (hasMissingId) { - throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); + throw new InvalidRequestBodyException("Request body must include 'id' element.", "Expected 'id' element in 'data' element.", body); } - + if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) { throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); @@ -141,12 +148,6 @@ private bool HasMissingId(IEnumerable models) private static bool TryGetId(object model, out string id) { - if (model is ResourceObject resourceObject) - { - id = resourceObject.Id; - return true; - } - if (model is IIdentifiable identifiable) { id = identifiable.StringId; diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 145bd7b06c..5638f20824 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -37,12 +38,16 @@ public object Deserialize(string body) { if (body == null) throw new ArgumentNullException(nameof(body)); + var result = DeserializeBody(body); + if (_request.Kind == EndpointKind.Relationship) { _targetedFields.Relationships.Add(_request.Relationship); + + // AssertHasId(result); } - return DeserializeBody(body); + return result; } /// @@ -77,5 +82,30 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA else if (field is RelationshipAttribute relationship) _targetedFields.Relationships.Add(relationship); } + + private void AssertHasId(object deserialized) + { + if (deserialized != null) + { + IEnumerable resources; + + if (deserialized is IIdentifiable identifiable) + { + resources = new[] { identifiable }; + } + else + { + resources = (IEnumerable) deserialized; + } + + foreach (var r in resources) + { + if (string.IsNullOrEmpty(r.StringId)) + { + throw new InvalidRequestBodyException("Request body must include 'id' element.", "Expected 'id' element in 'data' element.",null); + } + } + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 5502703a63..74632de124 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -304,6 +304,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_add_for_missing_type() { @@ -340,7 +341,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } - + + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_add_for_unknown_type() { @@ -379,7 +381,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + // TODO: Consider moving to RequestDeserializerTests + [Fact] public async Task Cannot_add_for_missing_ID() { // Arrange @@ -624,6 +627,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); } + // TODO: Consider moving to RequestDeserializerTests [Fact] public async Task Cannot_add_for_relationship_mismatch_between_url_and_body() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 7cb528f994..3a5deeeb68 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -377,7 +377,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + // TODO: Consider moving to RequestDeserializerTests + [Fact] public async Task Cannot_remove_for_missing_ID() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index a485faaeea..740d2ee84a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -341,6 +341,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_replace_for_missing_type() { @@ -378,6 +379,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_replace_for_unknown_type() { @@ -416,7 +418,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + // TODO: Consider moving to RequestDeserializerTests + [Fact] public async Task Cannot_replace_for_missing_ID() { // Arrange @@ -661,6 +664,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); } + // TODO: Consider moving to RequestDeserializerTests [Fact] public async Task Cannot_replace_on_relationship_mismatch_between_url_and_body() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index 1da6cbf8bb..dce630032f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -8,6 +8,21 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships { + // TODO: + // consider using workItem instead of 'existingWorkItem'. + // - understandable without while not as verbose, less = more + // - in line with what we had/have + + // TODO: + // Consider using abbreviations instead of full parameter names in lambdas + // - in line with what we had + // - more readable because less verbose + + // TODO: + // Array.Empty() vs new object[0] + + // TODO: + // Double assertions public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> { @@ -18,7 +33,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext workItemInDatabase.AssignedTo.Should().BeNull(); + // TODO: When checking if workItemInDatabase.AssignedTo is null, there is no need to also check that userAccountInDatabase.AssignedItems is empty + var userAccountInDatabase = await dbContext.UserAccounts .Include(userAccount => userAccount.AssignedItems) .FirstOrDefaultAsync(userAccount => userAccount.Id == existingWorkItem.AssignedTo.Id); @@ -104,6 +121,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(rgbColor => rgbColor.Group) .ToListAsync(); + // TODO: Redundant: given that we're working with a OneToOne relationship, if colorInDatabase2 is assigned to existingGroup + // then it CANNOT be associated with colorInDatabase1 any more. this double assertion we're merely + // verifying that EF Core knows how to deals with relationships correctly, which I think is not the scope of this test. var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); colorInDatabase1.Group.Should().BeNull(); @@ -150,7 +170,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var groupsInDatabase = await dbContext.Groups .Include(group => group.Color) .ToListAsync(); - + var groupInDatabase1 = groupsInDatabase.Single(p => p.Id == existingGroups[0].Id); groupInDatabase1.Color.Should().BeNull(); @@ -226,6 +246,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_create_for_missing_type() { @@ -260,6 +281,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_create_for_unknown_type() { @@ -295,7 +317,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + // TODO: Consider moving to RequestDeserializerTests + [Fact] public async Task Cannot_create_for_missing_ID() { // Arrange @@ -365,6 +388,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); } + // TODO: This test is not specific to the XX Endpoint. [Fact] public async Task Cannot_create_on_unknown_resource_type_in_url() { @@ -468,6 +492,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); } + // TODO: Consider moving to RequestDeserializerTests [Fact] public async Task Cannot_create_on_relationship_mismatch_between_url_and_body() { From 527438385ba83d25b0ebdd15ff587a03d1c7f956 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 11:08:34 +0100 Subject: [PATCH 161/240] added missing test for create --- .../Writing/Creating/CreateResourceTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index a3913f9c73..08000e02e1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -471,5 +471,25 @@ public async Task Cannot_create_resource_with_readonly_attribute() responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); responseDocument.Errors[0].Detail.Should().StartWith("Property 'WorkItemGroup.ConcurrencyToken' is read-only. - Request body:"); } + + [Fact] + public async Task Cannot_create_resource_for_broken_JSON_request_body() + { + // Arrange + var requestBody = "{ \"data\" {"; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); + } } } From 333e3b064c8e9ce3227e2bc16e769a6763876196 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 12:57:39 +0100 Subject: [PATCH 162/240] added tests for updating resources without relationships --- .../Serialization/JsonApiReader.cs | 2 +- .../Serialization/RequestDeserializer.cs | 4 +- .../Acceptance/ManyToManyTests.cs | 2 + .../Spec/ResourceTypeMismatchTests.cs | 26 +- .../Acceptance/Spec/UpdatingDataTests.cs | 420 +-------- .../Spec/UpdatingRelationshipsTests.cs | 2 + .../Writing/Creating/CreateResourceTests.cs | 7 +- .../UpdateToOneRelationshipTests.cs | 1 - .../Updating/Resources/UpdateResourceTests.cs | 861 ++++++++++++++++++ .../Resources/UpdateToOneRelationshipTests.cs | 1 - .../IntegrationTests/Writing/WriteFakers.cs | 3 +- 11 files changed, 877 insertions(+), 452 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index ee06034d5e..939aa74963 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -114,7 +114,7 @@ private void ValidatePatchRequestIncludesId(InputFormatterContext context, objec if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); + throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.Path); } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 145bd7b06c..07b110e95c 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -60,8 +60,8 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { throw new InvalidRequestBodyException( - "Assigning to the requested attribute is not allowed.", - $"Assigning to '{attr.PublicName}' is not allowed.", null); + "Setting the initial value of the requested attribute is not allowed.", + $"Setting the initial value of '{attr.PublicName}' is not allowed.", null); } if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method && diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 6401d3735d..27d4affdb3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -10,6 +10,8 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance { + // TODO: Move left-over tests in this file. + public sealed class ManyToManyTests : IClassFixture> { private readonly IntegrationTestContext _testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs index 327f783954..c718af2eff 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -6,6 +6,8 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { + // TODO: Move left-over tests in this file. + public sealed class ResourceTypeMismatchTests : FunctionalTestCollection { public ResourceTypeMismatchTests(StandardApplicationFactory factory) : base(factory) { } @@ -32,29 +34,5 @@ public async Task Posting_Resource_With_Mismatching_Resource_Type_Returns_Confli Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); } - - [Fact] - public async Task Patching_Resource_With_Mismatching_Resource_Type_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "people", - id = 1 - } - }); - - // Act - var (body, _) = await Patch("/api/v1/todoItems/1", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1', instead of 'people'.", errorDocument.Errors[0].Detail); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 5b510a46bf..3fe50469c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -17,6 +17,8 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { + // TODO: Move left-over tests in this file. + public sealed class UpdatingDataTests : IClassFixture> { private readonly IntegrationTestContext _testContext; @@ -106,424 +108,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes.Should().NotContainKey("password"); } - [Fact] - public async Task Response_422_If_Updating_Not_Settable_Attribute() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["calculatedValue"] = "calculated" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); - responseDocument.Errors[0].Detail.Should().StartWith("Property 'TodoItem.CalculatedValue' is read-only. - Request body: <<"); - - loggerFactory.Logger.Messages.Should().HaveCount(2); - loggerFactory.Logger.Messages.Should().Contain(x => - x.Text.StartsWith("Received request at ") && x.Text.Contains("with body:")); - loggerFactory.Logger.Messages.Should().Contain(x => - x.Text.StartsWith("Sending 422 response for request at ") && - x.Text.Contains("Failed to deserialize request body.")); - } - - [Fact] - public async Task Respond_404_If_ResourceDoesNotExist() - { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = 99999999, - attributes = new Dictionary - { - ["description"] = "something else" - } - } - }; - - var route = "/api/v1/todoItems/" + 99999999; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' with ID '99999999' does not exist."); - } - - [Fact] - public async Task Respond_422_If_IdNotInAttributeList() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - attributes = new Dictionary - { - ["description"] = "something else" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); - } - - [Fact] - public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - int differentTodoItemId = todoItem.Id + 1; - - var requestBody = new - { - data = new - { - type = "todoItems", - id = differentTodoItemId, - attributes = new Dictionary - { - ["description"] = "something else" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); - responseDocument.Errors[0].Title.Should().Be("Resource ID mismatch between request body and endpoint URL."); - responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{todoItem.Id}' in PATCH request body at endpoint 'http://localhost/api/v1/todoItems/{todoItem.Id}', instead of '{differentTodoItemId}'."); - } - - [Fact] - public async Task Respond_422_If_Broken_JSON_Request_Body() - { - // Arrange - var requestBody = "{ \"data\" {"; - - var route = "/api/v1/todoItems/"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); - responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); - } - - [Fact] - public async Task Respond_422_If_Blocked_For_Update() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["offsetDate"] = "2000-01-01" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); - responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'offsetDate' is not allowed. - Request body:"); - } - - [Fact] - public async Task Can_Patch_Resource() - { - // Arrange - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = person.StringId, - attributes = new Dictionary - { - ["lastName"] = "Johnson" - } - } - }; - - var route = "/api/v1/people/" + person.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var updated = await dbContext.People - .FirstAsync(t => t.Id == person.Id); - - updated.LastName.Should().Be("Johnson"); - }); - } - - [Fact] - public async Task Can_Patch_Resource_And_Get_Response_With_Side_Effects() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - var currentStateOfAlwaysChangingValue = todoItem.AlwaysChangingValue; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["description"] = "something else", - ["ordinal"] = 1 - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["description"].Should().Be("something else"); - responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); - responseDocument.SingleData.Attributes["alwaysChangingValue"].Should().NotBe(currentStateOfAlwaysChangingValue); - responseDocument.SingleData.Relationships["owner"].SingleData.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var updated = await dbContext.TodoItems - .Include(t => t.Owner) - .FirstAsync(t => t.Id == todoItem.Id); - - updated.Description.Should().Be("something else"); - updated.Ordinal.Should().Be(1); - updated.AlwaysChangingValue.Should().NotBe(currentStateOfAlwaysChangingValue); - updated.Owner.Id.Should().Be(todoItem.Owner.Id); - }); - } - - [Fact] - public async Task Can_Patch_Resource_With_Side_Effects_And_Apply_Sparse_Field_Set_Selection() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["description"] = "something else", - ["ordinal"] = 1 - } - } - }; - - var route = $"/api/v1/todoItems/{todoItem.StringId}?fields=description,ordinal"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Should().NotBeNull(); - responseDocument.SingleData.Attributes.Should().HaveCount(2); - responseDocument.SingleData.Attributes["description"].Should().Be("something else"); - responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var updated = await dbContext.TodoItems - .Include(t => t.Owner) - .FirstAsync(t => t.Id == todoItem.Id); - - updated.Description.Should().Be("something else"); - updated.Ordinal.Should().Be(1); - updated.Owner.Id.Should().Be(todoItem.Owner.Id); - }); - } - - // TODO: Add test(s) that save a relationship, then return its data via include. - - [Fact] - public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = todoItem.Owner.StringId, - attributes = new Dictionary - { - ["firstName"] = "#John", - ["lastName"] = "Doe" - } - } - }; - - var route = "/api/v1/people/" + todoItem.Owner.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["firstName"].Should().Be("#John"); - responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); - responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); - responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); - } - [Fact] public async Task Can_Patch_Resource_And_HasOne_Relationships() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index f91db5755b..4b42fbc8f1 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -13,6 +13,8 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { + // TODO: Move left-over tests in this file. + public sealed class UpdatingRelationshipsTests : IClassFixture> { private readonly IntegrationTestContext _testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 08000e02e1..21391d3391 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -382,8 +382,7 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist."); - responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] @@ -438,8 +437,8 @@ public async Task Cannot_create_resource_with_blocked_attribute() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Assigning to the requested attribute is not allowed."); - responseDocument.Errors[0].Detail.Should().StartWith("Assigning to 'concurrencyToken' is not allowed. - Request body:"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Setting the initial value of 'concurrencyToken' is not allowed. - Request body:"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index 1da6cbf8bb..e72aff754c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -359,7 +359,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000000..1c05bd46ef --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -0,0 +1,861 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +{ + public sealed class UpdateResourceTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + options.AllowClientGeneratedIds = false; + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + }, + relationships = new + { + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_update_resource_with_unknown_attribute() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newFirstName = _fakers.UserAccount.Generate().FirstName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newFirstName, + doesNotExist = "Ignored" + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == existingUserAccount.Id); + + userAccountInDatabase.FirstName.Should().Be(newFirstName); + userAccountInDatabase.LastName.Should().Be(existingUserAccount.LastName); + }); + } + + [Fact] + public async Task Can_partially_update_resource_with_guid_ID() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + var newName = _fakers.WorkItemGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId, + attributes = new + { + name = newName + } + } + }; + + var route = "/workItemGroups/" + existingGroup.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(existingGroup.StringId); + responseDocument.SingleData.Attributes["name"].Should().Be(newName); + responseDocument.SingleData.Attributes["isPublic"].Should().Be(existingGroup.IsPublic); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == existingGroup.Id); + + groupInDatabase.Name.Should().Be(newName); + groupInDatabase.IsPublic.Should().Be(existingGroup.IsPublic); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_completely_update_resource_with_string_ID() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + var newDisplayName = _fakers.RgbColor.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + attributes = new + { + displayName = newDisplayName + } + } + }; + + var route = "/rgbColors/" + existingColor.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == existingColor.Id); + + colorInDatabase.DisplayName.Should().Be(newDisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Can_update_resource_without_side_effects() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newUserAccount.FirstName, + lastName = newUserAccount.LastName + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == existingUserAccount.Id); + + userAccountInDatabase.FirstName.Should().Be(newUserAccount.FirstName); + userAccountInDatabase.LastName.Should().Be(newUserAccount.LastName); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + responseDocument.SingleData.Attributes.Should().ContainKey("concurrencyToken"); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects_with_primary_fieldset() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=description,priority"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects_with_include_and_fieldsets() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=description,priority&include=tags&fields[tags]=text"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + + responseDocument.SingleData.Relationships.Should().ContainKey("tags"); + responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); + responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("workTags"); + responseDocument.Included[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes["text"].Should().Be(existingWorkItem.WorkItemTags.Single().Tag.Text); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Values.Should().OnlyContain(relationshipEntry => relationshipEntry.Data == null); + + responseDocument.Included.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "doesNotExist", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems" + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = "/doesNotExist/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_ID_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + id = 99999999 + } + }; + + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_update_on_resource_type_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workItems' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}', instead of 'rgbColors'."); + } + + [Fact] + public async Task Cannot_update_on_resource_ID_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.AddRange(existingWorkItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItems[0].StringId + } + }; + + var route = "/workItems/" + existingWorkItems[1].StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource ID mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint '/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); + } + + [Fact] + public async Task Cannot_update_resource_with_blocked_attribute() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'concurrencyToken' is not allowed. - Request body:"); + } + + [Fact] + public async Task Cannot_update_resource_with_readonly_attribute() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingWorkItem.StringId, + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItemGroups/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Property 'WorkItemGroup.ConcurrencyToken' is read-only. - Request body:"); + } + + [Fact] + public async Task Cannot_update_resource_for_broken_JSON_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = "{ \"data\" {"; + + var route = "/workItemGroups/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Cannot_change_ID_of_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + id = existingWorkItem.Id + 123456 + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Resource ID is read-only."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 9faf6063ee..32619dd28e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -447,7 +447,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs index 10d71bc54c..924701b58d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -31,7 +31,8 @@ internal class WriteFakers private readonly Lazy> _lazyWorkItemGroupFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Name, f => f.Lorem.Word())); + .RuleFor(p => p.Name, f => f.Lorem.Word()) + .RuleFor(p => p.IsPublic, f => f.Random.Bool())); private readonly Lazy> _lazyRgbColorFaker = new Lazy>(() => new Faker() From 583ba5091704b29577e79e35a9fe1565176e1897 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 28 Oct 2020 13:37:33 +0100 Subject: [PATCH 163/240] chore: add review todos --- .../Serialization/JsonApiReader.cs | 2 -- .../Serialization/RequestDeserializer.cs | 1 + .../Writing/Creating/CreateResourceTests.cs | 6 +++++ .../CreateResourceWithRelationshipTests.cs | 14 ++++++++++- .../Writing/Deleting/DeleteResourceTests.cs | 10 ++++++++ .../AddToToManyRelationshipTests.cs | 3 +++ .../RemoveFromToManyRelationshipTests.cs | 13 +++++++++++ .../ReplaceToManyRelationshipTests.cs | 6 ++++- .../UpdateToOneRelationshipTests.cs | 23 ++++++------------- .../IntegrationTests/Writing/UserAccount.cs | 1 + .../IntegrationTests/Writing/WorkItemGroup.cs | 1 + 11 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 7019ce29f0..f6ab973ca8 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -4,13 +4,11 @@ using System.IO; using System.Net.Http; using System.Linq; -using System.Security.Cryptography.Xml; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 5638f20824..6cf2e321f9 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -44,6 +44,7 @@ public object Deserialize(string body) { _targetedFields.Relationships.Add(_request.Relationship); + // TODO: consider doing assertions from JsonApiReader here. // AssertHasId(result); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 21c0854f08..2fc71e71af 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -99,6 +99,7 @@ public async Task Can_create_resource_with_int_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { + // TODO: For consistency, fetch with FirstAsync and check for null var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); @@ -149,6 +150,7 @@ public async Task Can_create_resource_with_long_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { + // TODO: For consistency, fetch with FirstAsync and check for null var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); var newUserAccountInDatabase = userAccountsInDatabase.Single(p => p.StringId == newUserAccountId); @@ -356,6 +358,7 @@ public async Task Cannot_create_resource_for_missing_type() responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } + // TODO: Consider moving to BaseDocumentParserTests. [Fact] public async Task Cannot_create_resource_for_unknown_type() { @@ -386,6 +389,7 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors[0].Detail.Should().Contain("Request body: <<"); } + // TODO: Can we rename this to something with "AttrCapabilities" to be more explicit? Right now I needed to go to the model to understand the test. [Fact] public async Task Cannot_create_resource_with_blocked_attribute() { @@ -416,6 +420,8 @@ public async Task Cannot_create_resource_with_blocked_attribute() responseDocument.Errors[0].Detail.Should().StartWith("Assigning to 'concurrencyToken' is not allowed. - Request body:"); } + // TODO: Deserialization issues because of properties not having setters is something I would prefer testing in unit tests. + // the other one above with "blocked" attributes seems more suitable for a unit test because there it is about a feature that is transcends just the serializer. [Fact] public async Task Cannot_create_resource_with_readonly_attribute() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index d8a85332bd..73d54c8c6c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -72,14 +72,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { + // TODO: Consistency: use FirstAsync(). var groupsInDatabase = await dbContext.Groups .Include(group => group.Color) .ToListAsync(); - + var newGroupInDatabase = groupsInDatabase.Single(p => p.StringId == newGroupId); newGroupInDatabase.Color.Should().NotBeNull(); newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); + // TODO: Double assertions. var existingGroupInDatabase = groupsInDatabase.Single(p => p.Id == existingGroup.Id); existingGroupInDatabase.Color.Should().BeNull(); }); @@ -140,6 +142,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => newColorInDatabase.Group.Should().NotBeNull(); newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); + // TODO: Double assertions. var existingColorInDatabase = colorsInDatabase.Single(p => p.Id == existingColor.Id); existingColorInDatabase.Group.Should().BeNull(); }); @@ -198,6 +201,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { + // TODO: Consistency: use FirstAsync(). var workItemsInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) .ToListAsync(); @@ -270,6 +274,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { + // TODO: Consistency: use FirstAsync(). var workItemsInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) .ToListAsync(); @@ -282,6 +287,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() { @@ -318,6 +325,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignedTo' relationship. - Request body: <<"); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() { @@ -763,6 +771,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: It is interesting that this one does not fail. Because of the unknown type it doesn't make it further than the serializer + // If the type was known it would fail. That seems inconsistent. Consider changing the serializer to throw on invalid relationships. [Fact] public async Task Can_create_resource_with_unknown_relationship() { @@ -809,6 +819,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() { @@ -848,6 +859,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index ee9125b957..307a18b476 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -52,6 +52,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Should this really fail? + // spec says: "A server SHOULD return a 404 Not Found status code if a deletion request fails due to the resource not existing. + // Given the technical implementation, the deletion requests does not have to "fail". Deleting from a table where record.id = X where the X does not exist in the table is not a failure. [Fact] public async Task Cannot_delete_missing_resource() { @@ -110,6 +113,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: How do we want JADNC to deal with this? + // I think this should only fail if the relationships are required in the models. Otherwise we should be able to work around the constraint violation. + // If we can delete from dependent side, why shouldn't we be able to delete from principal side? This leaks implementation details. + // In any case we shouldn't return 500. [Fact] public async Task Cannot_delete_existing_resource_with_OneToOne_relationship_from_principal_side() { @@ -140,6 +147,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => stackTrace.Should().Contain("violates foreign key constraint"); } + // TODO: How do we want JADNC to deal with this? + // I think this should only fail if the relationships are required in the models. Otherwise we should be able to work around the constraint violation. + // In any case we shouldn't return 500. [Fact] public async Task Cannot_delete_existing_resource_with_HasMany_relationship() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 74632de124..1c6e6eef6c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -515,6 +515,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); } + // TODO: This is a very general 404 test which is not exclusive to this or any of the other endpoints where it is duplicated. [Fact] public async Task Cannot_add_to_unknown_resource_type_in_url() { @@ -628,6 +629,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } // TODO: Consider moving to RequestDeserializerTests + // TODO: Inconsistent naming: "on" vs for" compared to equivalent tests in UpdateToOneRelationshipTests and ReplaceToManyRelationshipTests [Fact] public async Task Cannot_add_for_relationship_mismatch_between_url_and_body() { @@ -732,6 +734,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { + // TODO: Array.Empty() data = new object[0] }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 3a5deeeb68..650104ab02 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -103,6 +103,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + // TODO: Seems unnecessary. var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); userAccountsInDatabase.Should().HaveCount(2); }); @@ -173,6 +174,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Should().HaveCount(1); workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(2).Tag.Id); + // TODO: Seems unnecessary. var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); tagsInDatabase.Should().HaveCount(3); }); @@ -229,6 +231,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + // TODO: Seems unnecessary. var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); userAccountsInDatabase.Should().HaveCount(3); }); @@ -301,6 +304,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_remove_for_missing_type() { @@ -339,6 +343,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_remove_for_unknown_type() { @@ -415,6 +420,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); } + // TODO: These might be by design (404 vs 403 and Primary vs Relationship endpoint spec issue). [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] public async Task Cannot_remove_unknown_IDs_from_HasMany_relationship() { @@ -463,6 +469,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being removed from relationship 'subscribers' does not exist."); } + // TODO: These might be by design (404 vs 403 and Primary vs Relationship endpoint spec issue). [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] public async Task Cannot_remove_unknown_IDs_from_HasManyThrough_relationship() { @@ -511,6 +518,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being removed from relationship 'tags' does not exist."); } + // TODO: This is a very general 404 test which is not exclusive to this or any of the other endpoints where it is duplicated. [Fact] public async Task Cannot_remove_from_unknown_resource_type_in_url() { @@ -547,6 +555,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } + // TODO: These might be by design (404 vs 403 and Primary vs Relationship endpoint spec issue). [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] public async Task Cannot_remove_from_unknown_resource_ID_in_url() { @@ -623,6 +632,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); } + // TODO: Consider moving to RequestDeserializerTests + // TODO: Inconsistent naming: "on" vs for" compared to equivalent tests in UpdateToOneRelationshipTests and ReplaceToManyRelationshipTests [Fact] public async Task Cannot_remove_for_relationship_mismatch_between_url_and_body() { @@ -662,6 +673,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in DELETE request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Can_remove_with_duplicates() { @@ -728,6 +740,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { + // TODO: Array.Empty() data = new object[0] }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 8d2dd8f30d..18d132cff8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -552,6 +552,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); } + // TODO: This is a very general 404 test which is not exclusive to this or any of the other endpoints where it is duplicated. [Fact] public async Task Cannot_replace_on_unknown_resource_type_in_url() { @@ -703,7 +704,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); } - + + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Can_replace_with_duplicates() { @@ -757,6 +759,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_replace_with_null_data_in_HasMany_relationship() { @@ -788,6 +791,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().BeNull(); } + // TODO: Consider moving to BaseDocumentParserTests [Fact] public async Task Cannot_replace_with_null_data_in_HasManyThrough_relationship() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index dce630032f..12db28e19c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -8,21 +8,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships { - // TODO: - // consider using workItem instead of 'existingWorkItem'. + // TODO: consider using instead of existing // - understandable without while not as verbose, less = more // - in line with what we had/have - - // TODO: - // Consider using abbreviations instead of full parameter names in lambdas + + // TODO: Consider using abbreviations instead of full parameter names in lambdas // - in line with what we had // - more readable because less verbose - - // TODO: - // Array.Empty() vs new object[0] - - // TODO: - // Double assertions public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> { @@ -70,8 +62,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.AssignedTo.Should().BeNull(); - // TODO: When checking if workItemInDatabase.AssignedTo is null, there is no need to also check that userAccountInDatabase.AssignedItems is empty - + // TODO: When checking if workItemInDatabase.AssignedTo is null, there is no need to also check that userAccountInDatabase.AssignedItems is empty. var userAccountInDatabase = await dbContext.UserAccounts .Include(userAccount => userAccount.AssignedItems) .FirstOrDefaultAsync(userAccount => userAccount.Id == existingWorkItem.AssignedTo.Id); @@ -122,8 +113,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .ToListAsync(); // TODO: Redundant: given that we're working with a OneToOne relationship, if colorInDatabase2 is assigned to existingGroup - // then it CANNOT be associated with colorInDatabase1 any more. this double assertion we're merely - // verifying that EF Core knows how to deals with relationships correctly, which I think is not the scope of this test. + // then it CANNOT be associated with colorInDatabase1 any more. With this double assertion we're merely + // verifying that EF Core knows how to deals with relationships correctly. var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); colorInDatabase1.Group.Should().BeNull(); @@ -388,7 +379,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); } - // TODO: This test is not specific to the XX Endpoint. + // TODO: This is a very general 404 test which is not exclusive to this or any of the other endpoints where it is duplicated. [Fact] public async Task Cannot_create_on_unknown_resource_type_in_url() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs index 1e4b60d612..1090e206ff 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs @@ -4,6 +4,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { + // TODO: Why not just "User"? That would seem more intuitive to me. public sealed class UserAccount : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs index 37dc8cd78c..824158aaf8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs @@ -6,6 +6,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { + // TODO: What does a WorkItemGroup represent? I'm not so sure about this being an intuitive model like Article with Authors etc. public sealed class WorkItemGroup : Identifiable { [Attr] From 258f224c8f8ec14854af64b5b3863016be312cd0 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 28 Oct 2020 13:45:53 +0100 Subject: [PATCH 164/240] feat: hook executor is no longer an optional dependency --- .../JsonApiApplicationBuilder.cs | 24 +++++---- .../Internal/NullResourceHookExecutor.cs | 28 ++++++++++ .../Services/JsonApiResourceService.cs | 53 ++++++++++++------- 3 files changed, 76 insertions(+), 29 deletions(-) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 35768f220a..f6a7d97f3d 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -139,10 +139,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) AddSerializationLayer(); AddQueryStringLayer(); - if (_options.EnableResourceHooks) - { - AddResourceHooks(); - } + AddResourceHooks(); _services.AddScoped(); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); @@ -240,12 +237,19 @@ private void AddQueryStringLayer() } private void AddResourceHooks() - { - _services.AddSingleton(typeof(IHooksDiscovery<>), typeof(HooksDiscovery<>)); - _services.AddScoped(typeof(IResourceHookContainer<>), typeof(ResourceHooksDefinition<>)); - _services.AddTransient(typeof(IResourceHookExecutor), typeof(ResourceHookExecutor)); - _services.AddTransient(); - _services.AddTransient(); + { + if (_options.EnableResourceHooks) + { + _services.AddSingleton(typeof(IHooksDiscovery<>), typeof(HooksDiscovery<>)); + _services.AddScoped(typeof(IResourceHookContainer<>), typeof(ResourceHooksDefinition<>)); + _services.AddTransient(); + _services.AddTransient(); + _services.AddTransient(); + } + else + { + _services.AddTransient(); + } } private void AddSerializationLayer() diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs new file mode 100644 index 0000000000..eb42d329b5 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + internal sealed class NullResourceHookExecutor : IResourceHookExecutor + { + public void BeforeRead(ResourcePipeline pipeline, string stringId = null) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public IEnumerable BeforeUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public IEnumerable BeforeCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public IEnumerable BeforeDelete(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable => throw new NotImplementedException(); + + public IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1e82fdcdc8..a7a8c510ec 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -47,7 +47,7 @@ public JsonApiResourceService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor = null) + IResourceHookExecutor hookExecutor) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -62,7 +62,7 @@ public JsonApiResourceService( _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - _hookExecutor = hookExecutor; + _hookExecutor = hookExecutor ?? throw new ArgumentNullException(nameof(hookExecutor)); } /// @@ -70,7 +70,10 @@ public virtual async Task> GetAsync() { _traceWriter.LogMethodStart(); - _hookExecutor?.BeforeRead(ResourcePipeline.Get); + if (_hookExecutor is ResourceHookExecutor) + { + _hookExecutor.BeforeRead(ResourcePipeline.Get); + } if (_options.IncludeTotalResourceCount) { @@ -86,7 +89,7 @@ public virtual async Task> GetAsync() var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); var resources = await _repository.GetAsync(queryLayer); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { _hookExecutor.AfterRead(resources, ResourcePipeline.Get); return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); @@ -105,11 +108,14 @@ public virtual async Task GetAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - _hookExecutor?.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); + if (_hookExecutor is ResourceHookExecutor) + { + _hookExecutor.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); + } var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.PreserveExisting); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetSingle); return _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetSingle).Single(); @@ -124,7 +130,10 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN _traceWriter.LogMethodStart(new {id, relationshipName}); AssertRelationshipExists(relationshipName); - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + if (_hookExecutor is ResourceHookExecutor) + { + _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + } var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); @@ -142,7 +151,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); @@ -167,7 +176,10 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh AssertRelationshipExists(relationshipName); - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + if (_hookExecutor is ResourceHookExecutor) + { + _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + } var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); @@ -180,7 +192,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); @@ -203,7 +215,7 @@ public virtual async Task CreateAsync(TResource resource) _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { resourceFromRequest = _hookExecutor.BeforeCreate(ToList(resourceFromRequest), ResourcePipeline.Post).Single(); } @@ -220,7 +232,7 @@ public virtual async Task CreateAsync(TResource resource) var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.PreserveExisting); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { _hookExecutor.AfterCreate(ToList(resourceFromDatabase), ResourcePipeline.Post); resourceFromDatabase = _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Post).Single(); @@ -280,7 +292,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { resourceFromRequest = _hookExecutor.BeforeUpdate(ToList(resourceFromRequest), ResourcePipeline.Patch).Single(); } @@ -295,7 +307,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); @@ -323,7 +335,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, TResource primaryResource = null; - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); @@ -346,7 +358,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, throw; } - if (_hookExecutor != null && primaryResource != null) + if (_hookExecutor is ResourceHookExecutor && primaryResource != null) { _hookExecutor.AfterUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); } @@ -358,7 +370,7 @@ public virtual async Task DeleteAsync(TId id) _traceWriter.LogMethodStart(new {id}); TResource resource = null; - if (_hookExecutor != null) + if (_hookExecutor is ResourceHookExecutor) { resource = _resourceFactory.CreateInstance(); resource.Id = id; @@ -381,7 +393,10 @@ public virtual async Task DeleteAsync(TId id) } finally { - _hookExecutor?.AfterDelete(ToList(resource), ResourcePipeline.Delete, succeeded); + if (_hookExecutor is ResourceHookExecutor) + { + _hookExecutor.AfterDelete(ToList(resource), ResourcePipeline.Delete, succeeded); + } } } @@ -616,7 +631,7 @@ public JsonApiResourceService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor = null) + IResourceHookExecutor hookExecutor) : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } From 04585d024a7b5d510ffc89b135ab667a0c403779 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 28 Oct 2020 13:47:48 +0100 Subject: [PATCH 165/240] feat: hook executor is no longer an optional dependency --- .../Configuration/JsonApiApplicationBuilder.cs | 2 +- .../Hooks/Internal/NullResourceHookExecutor.cs | 2 ++ test/UnitTests/Services/DefaultResourceService_Tests.cs | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index f6a7d97f3d..2e0d2f1c9c 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -248,7 +248,7 @@ private void AddResourceHooks() } else { - _services.AddTransient(); + _services.AddTransient(_ => NullResourceHookExecutor.Instance); } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs index eb42d329b5..1906673409 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs @@ -7,6 +7,8 @@ namespace JsonApiDotNetCore.Hooks.Internal { internal sealed class NullResourceHookExecutor : IResourceHookExecutor { + internal static NullResourceHookExecutor Instance = new NullResourceHookExecutor(); + public void BeforeRead(ResourcePipeline pipeline, string stringId = null) where TResource : class, IIdentifiable => throw new NotImplementedException(); public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index cb09668ea9..93bd9c2ccb 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Internal; @@ -91,7 +92,7 @@ private JsonApiResourceService GetService() return new JsonApiResourceService(_repositoryMock.Object, repositoryAccessor, composer, paginationContext, options, NullLoggerFactory.Instance, request, changeTracker, resourceFactory, - targetedFields, resourceContextProvider); + targetedFields, resourceContextProvider, NullResourceHookExecutor.Instance); } } } From 45f49660a05327ff8ef8b7559db0ffb90ad075ff Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 13:50:48 +0100 Subject: [PATCH 166/240] added tests for attribute type mismatch --- .../Writing/Creating/CreateResourceTests.cs | 30 ++++++++++++++ .../Updating/Resources/UpdateResourceTests.cs | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 21391d3391..279136695e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -490,5 +490,35 @@ public async Task Cannot_create_resource_for_broken_JSON_request_body() responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); } + + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + dueAt = "not-a-valid-time" + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 1c05bd46ef..cbb2235c37 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -857,5 +857,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Resource ID is read-only."); responseDocument.Errors[0].Detail.Should().BeNull(); } + + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + dueAt = "not-a-valid-time" + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); + } } } From 6333be3fc535e91e291786f2f6cfb14550c66029 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 13:54:49 +0100 Subject: [PATCH 167/240] removed duplicate tests --- .../ReplaceToManyRelationshipTests.cs | 96 ------------------- .../Resources/UpdateToOneRelationshipTests.cs | 90 ----------------- 2 files changed, 186 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index bd3ce5bd29..ae8c90d4d4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -669,102 +669,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); } - [Fact] - public async Task Cannot_replace_on_unknown_resource_type_in_url() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - id = existingWorkItem.StringId, - relationships = new - { - subscribers = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } - } - } - } - }; - - var route = "/doesNotExist/" + existingWorkItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Cannot_replace_on_unknown_resource_ID_in_url() - { - // Arrange - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.Add(existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - id = 99999999, - relationships = new - { - subscribers = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } - } - } - } - }; - - var route = "/workItems/99999999"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); - } - [Fact] public async Task Can_replace_with_duplicates() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 32619dd28e..e818991cbe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -451,95 +451,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); } - - [Fact] - public async Task Cannot_create_on_unknown_resource_type_in_url() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - var existingUserAccount = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingUserAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - id = existingWorkItem.StringId, - relationships = new - { - assignedTo = new - { - data = new - { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - } - } - }; - - var route = "/doesNotExist/" + existingWorkItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Cannot_create_on_unknown_resource_ID_in_url() - { - // Arrange - var existingUserAccount = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.Add(existingUserAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - id = 99999999, - relationships = new - { - assignedTo = new - { - data = new - { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - } - } - }; - - var route = "/workItems/99999999"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); - } } } From fbcc23fc7b1d816a75a599bc89ff3aee986ade4b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 17:09:50 +0100 Subject: [PATCH 168/240] Added tests for missing request body; fixed tests for missing ID in body --- .../Serialization/JsonApiReader.cs | 183 ++++++++++-------- .../Writing/Creating/CreateResourceTests.cs | 20 ++ .../AddToToManyRelationshipTests.cs | 32 ++- .../RemoveFromToManyRelationshipTests.cs | 32 ++- .../ReplaceToManyRelationshipTests.cs | 32 ++- .../UpdateToOneRelationshipTests.cs | 32 ++- .../Updating/Resources/UpdateResourceTests.cs | 28 +++ 7 files changed, 271 insertions(+), 88 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 939aa74963..03150c6cd7 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -4,6 +4,7 @@ using System.IO; using System.Net.Http; using System.Linq; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -43,89 +44,142 @@ public async Task ReadAsync(InputFormatterContext context) if (context == null) throw new ArgumentNullException(nameof(context)); - var request = context.HttpContext.Request; - if (request.ContentLength == 0) - { - return await InputFormatterResult.SuccessAsync(null); - } - - string body = await GetRequestBody(context.HttpContext.Request.Body); + string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); string url = context.HttpContext.Request.GetEncodedUrl(); _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); - object model; - try + object model = null; + if (!string.IsNullOrWhiteSpace(body)) { - model = _deserializer.Deserialize(body); + try + { + model = _deserializer.Deserialize(body); + } + catch (InvalidRequestBodyException exception) + { + exception.SetRequestBody(body); + throw; + } + catch (Exception exception) + { + throw new InvalidRequestBodyException(null, null, body, exception); + } } - catch (InvalidRequestBodyException exception) + + if (RequiresRequestBody(context.HttpContext.Request.Method)) { - exception.SetRequestBody(body); - throw; + ValidateRequestBody(model, body, context.HttpContext.Request); } - catch (Exception exception) + + return await InputFormatterResult.SuccessAsync(model); + } + + private async Task GetRequestBodyAsync(Stream bodyStream) + { + using var reader = new StreamReader(bodyStream); + return await reader.ReadToEndAsync(); + } + + private bool RequiresRequestBody(string requestMethod) + { + if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Patch) { - throw new InvalidRequestBodyException(null, null, body, exception); + return true; } - ValidatePatchRequestIncludesId(context, model, body); - - ValidateIncomingResourceType(context, model); - - return await InputFormatterResult.SuccessAsync(model); + return requestMethod == HttpMethods.Delete && _request.Kind == EndpointKind.Relationship; } - private void ValidateIncomingResourceType(InputFormatterContext context, object model) + private void ValidateRequestBody(object model, string body, HttpRequest httpRequest) { - if (context.HttpContext.IsJsonApiRequest() && context.HttpContext.Request.Method != HttpMethods.Get) + if (model == null && string.IsNullOrWhiteSpace(body)) { - var endpointResourceType = GetEndpointResourceType(); - if (endpointResourceType == null) - { - return; - } - - var bodyResourceTypes = GetBodyResourceTypes(model); - foreach (var bodyResourceType in bodyResourceTypes) + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { - if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) - { - var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); - var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); - - throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), - context.HttpContext.Request.Path, - resourceFromEndpoint, resourceFromBody); - } - } + Title = "Missing request body." + }); + } + + ValidateIncomingResourceType(model, httpRequest); + + if (httpRequest.Method != HttpMethods.Post || _request.Kind == EndpointKind.Relationship) + { + ValidateRequestIncludesId(model, body); + ValidatePrimaryIdValue(model, httpRequest.Path); } } - private void ValidatePatchRequestIncludesId(InputFormatterContext context, object model, string body) + private void ValidateIncomingResourceType(object model, HttpRequest httpRequest) { - if (context.HttpContext.Request.Method == HttpMethods.Patch) + var endpointResourceType = GetResourceTypeFromEndpoint(); + if (endpointResourceType == null) + { + return; + } + + var bodyResourceTypes = GetResourceTypesFromRequestBody(model); + foreach (var bodyResourceType in bodyResourceTypes) { - bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); - if (hasMissingId) + if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) { - throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); + var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); + var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); + + throw new ResourceTypeMismatchException(new HttpMethod(httpRequest.Method), + httpRequest.Path, resourceFromEndpoint, resourceFromBody); } + } + } + + private Type GetResourceTypeFromEndpoint() + { + return _request.Kind == EndpointKind.Primary + ? _request.PrimaryResource.ResourceType + : _request.SecondaryResource?.ResourceType; + } - if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) + private IEnumerable GetResourceTypesFromRequestBody(object model) + { + if (model is IEnumerable resourceCollection) + { + return resourceCollection.Select(r => r.GetType()).Distinct(); + } + + return model == null ? Array.Empty() : new[] { model.GetType() }; + } + + private void ValidateRequestIncludesId(object model, string body) + { + bool hasMissingId = model is IEnumerable list ? HasMissingId(list) : HasMissingId(model); + if (hasMissingId) + { + throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); + } + } + + private void ValidatePrimaryIdValue(object model, PathString requestPath) + { + if (_request.Kind == EndpointKind.Primary) + { + if (TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.Path); + throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, requestPath); } } } - /// Checks if the deserialized request body has an ID included + /// + /// Checks if the deserialized request body has an ID included. + /// private bool HasMissingId(object model) { return TryGetId(model, out string id) && string.IsNullOrEmpty(id); } - /// Checks if all elements in the deserialized request body have an ID included + /// + /// Checks if all elements in the deserialized request body have an ID included. + /// private bool HasMissingId(IEnumerable models) { foreach (var model in models) @@ -156,36 +210,5 @@ private static bool TryGetId(object model, out string id) id = null; return false; } - - /// - /// Fetches the request from body asynchronously. - /// - /// Input stream for body - /// String content of body sent to server. - private async Task GetRequestBody(Stream body) - { - using var reader = new StreamReader(body); - // This needs to be set to async because - // Synchronous IO operations are - // https://github.com/aspnet/AspNetCore/issues/7644 - return await reader.ReadToEndAsync(); - } - - private IEnumerable GetBodyResourceTypes(object model) - { - if (model is IEnumerable resourceCollection) - { - return resourceCollection.Select(r => r.GetType()).Distinct(); - } - - return model == null ? Array.Empty() : new[] { model.GetType() }; - } - - private Type GetEndpointResourceType() - { - return _request.Kind == EndpointKind.Primary - ? _request.PrimaryResource.ResourceType - : _request.SecondaryResource?.ResourceType; - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 279136695e..9b3f4afb1c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -328,6 +328,26 @@ public async Task Cannot_create_resource_with_client_generated_ID() responseDocument.Errors[0].Source.Pointer.Should().Be("/data/id"); } + [Fact] + public async Task Cannot_create_resource_for_missing_request_body() + { + // Arrange + var requestBody = string.Empty; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + [Fact] public async Task Cannot_create_resource_for_missing_type() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 5502703a63..94788cae7e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -304,6 +304,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_add_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + [Fact] public async Task Cannot_add_for_missing_type() { @@ -379,7 +407,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_add_for_missing_ID() { // Arrange @@ -413,7 +441,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 7cb528f994..da63b4bb07 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -301,6 +301,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_remove_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + [Fact] public async Task Cannot_remove_for_missing_type() { @@ -377,7 +405,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_remove_for_missing_ID() { // Arrange @@ -411,7 +439,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); } [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 5499f2bb52..59a5e15c0c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -341,6 +341,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_replace_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + [Fact] public async Task Cannot_replace_for_missing_type() { @@ -416,7 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_replace_for_missing_ID() { // Arrange @@ -450,7 +478,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index e72aff754c..fbfd4470d4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -226,6 +226,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_replace_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + [Fact] public async Task Cannot_create_for_missing_type() { @@ -295,7 +323,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_create_for_missing_ID() { // Arrange @@ -326,7 +354,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'data' element. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index cbb2235c37..1bec947d51 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -481,6 +481,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().BeNull(); } + [Fact] + public async Task Cannot_update_resource_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + [Fact] public async Task Cannot_update_resource_for_missing_type() { From 590860b09baae91cefb281c85d4cf2660d96bfc2 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 28 Oct 2020 17:54:05 +0100 Subject: [PATCH 169/240] cleanup assertions and fixed test name --- .../UpdateToOneRelationshipTests.cs | 21 +------------------ .../Resources/UpdateToOneRelationshipTests.cs | 21 +------------------ 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index fbfd4470d4..fbae75e368 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -20,7 +20,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.AssignedTo.Should().BeNull(); - - var userAccountInDatabase = await dbContext.UserAccounts - .Include(userAccount => userAccount.AssignedItems) - .FirstOrDefaultAsync(userAccount => userAccount.Id == existingWorkItem.AssignedTo.Id); - - userAccountInDatabase.Should().NotBeNull(); - userAccountInDatabase.AssignedItems.Should().BeEmpty(); }); } @@ -211,18 +204,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var workItemInDatabase2 = workItemsInDatabase.Single(p => p.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); workItemInDatabase2.AssignedTo.Should().NotBeNull(); workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); - - var userAccountsInDatabase = await dbContext.UserAccounts - .Include(userAccount => userAccount.AssignedItems) - .ToListAsync(); - - var userAccountInDatabase1 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[0].Id); - userAccountInDatabase1.AssignedItems.Should().HaveCount(1); - userAccountInDatabase1.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(0).Id); - - var userAccountInDatabase2 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[1].Id); - userAccountInDatabase2.AssignedItems.Should().HaveCount(1); - userAccountInDatabase2.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(1).Id); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index e818991cbe..b87dee2801 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -20,7 +20,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.AssignedTo.Should().BeNull(); - - var userAccountInDatabase = await dbContext.UserAccounts - .Include(userAccount => userAccount.AssignedItems) - .FirstOrDefaultAsync(userAccount => userAccount.Id == existingWorkItem.AssignedTo.Id); - - userAccountInDatabase.Should().NotBeNull(); - userAccountInDatabase.AssignedItems.Should().BeEmpty(); }); } @@ -255,18 +248,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var workItemInDatabase2 = workItemsInDatabase.Single(p => p.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); workItemInDatabase2.AssignedTo.Should().NotBeNull(); workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); - - var userAccountsInDatabase = await dbContext.UserAccounts - .Include(userAccount => userAccount.AssignedItems) - .ToListAsync(); - - var userAccountInDatabase1 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[0].Id); - userAccountInDatabase1.AssignedItems.Should().HaveCount(1); - userAccountInDatabase1.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(0).Id); - - var userAccountInDatabase2 = userAccountsInDatabase.Single(userAccount => userAccount.Id == existingUserAccounts[1].Id); - userAccountInDatabase2.AssignedItems.Should().HaveCount(1); - userAccountInDatabase2.AssignedItems.Single().Id.Should().Be(existingUserAccounts[0].AssignedItems.ElementAt(1).Id); }); } From 914c8a2033d35ac34f0492fd881a891500243bbf Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 29 Oct 2020 10:41:01 +0100 Subject: [PATCH 170/240] chore: add review todos --- test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs | 1 + .../Updating/Relationships/ReplaceToManyRelationshipTests.cs | 4 +++- .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 4 +++- .../Writing/Updating/Resources/UpdateResourceTests.cs | 3 ++- .../Updating/Resources/UpdateToOneRelationshipTests.cs | 4 ++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index e991398e8e..521f226927 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -40,6 +40,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 7aefe95540..2b5d1d4834 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -103,6 +103,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: This case is already covered by the Can_replace_HasMany_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasMany_relationship() { @@ -151,6 +152,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasManyThrough_relationship() { @@ -212,7 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); }); } - + [Fact] public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index ae8c90d4d4..c2f0b4c352 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -123,7 +123,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Should().BeEmpty(); }); } - + + // TODO: This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasMany_relationship() { @@ -183,6 +184,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: This case is already covered by the Can_replace_HasMany_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasManyThrough_relationship() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 1bec947d51..41321a8a07 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -46,7 +46,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "userAccounts", id = existingUserAccount.StringId, attributes = new - { + { // TODO: can we just inline this? }, relationships = new { @@ -741,6 +741,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint '/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); } + // TODO: Improve "blocked" naming, see other todo item in update test suite. [Fact] public async Task Cannot_update_resource_with_blocked_attribute() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index b87dee2801..96f009c9a6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -8,6 +8,9 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources { + // TODO: Tests for mismatch between type in relationship data versus expected clr type based on the relationship being populated./ + // - POST /primaryResource (HasOne, HasMany and HasManyThrough) + // - PATCH /primary resource (HasOne, HasMany and HasManyThrough) public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> { @@ -241,6 +244,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { + // TODO: Use FirstAsync with non-null assertion. var workItemsInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) .ToListAsync(); From 526a00ec3064e2c368b4765e9c2c7ee9b6f96541 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 29 Oct 2020 13:22:13 +0100 Subject: [PATCH 171/240] chore: review todos --- src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs | 2 ++ .../Serialization/RequestDeserializer.cs | 1 - .../Writing/Creating/CreateResourceTests.cs | 9 ++++----- .../Creating/CreateResourceWithRelationshipTests.cs | 2 +- .../Writing/Deleting/DeleteResourceTests.cs | 5 ++++- .../Relationships/RemoveFromToManyRelationshipTests.cs | 8 +++----- .../Relationships/ReplaceToManyRelationshipTests.cs | 2 ++ .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 6 ++++-- .../Writing/Updating/Resources/UpdateResourceTests.cs | 2 +- .../Updating/Resources/UpdateToOneRelationshipTests.cs | 4 ++-- .../IntegrationTests/Writing/WorkItem.cs | 1 + .../IntegrationTests/Writing/WorkItemGroup.cs | 1 - 12 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 6cc8d01599..04f916dfab 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -88,6 +88,8 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary { - // TODO: Use FirstAsync with non-null assertion. + // TODO: @Bart Use FirstAsync with non-null assertion. var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); @@ -150,7 +150,7 @@ public async Task Can_create_resource_with_long_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { - // TODO: Use FirstAsync with non-null assertion. + // TODO: @Bart Use FirstAsync with non-null assertion. var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); var newUserAccountInDatabase = userAccountsInDatabase.Single(p => p.StringId == newUserAccountId); @@ -433,7 +433,7 @@ public async Task Cannot_create_resource_on_unknown_resource_type_in_url() responseDocument.Should().BeEmpty(); } - // TODO: Can we rename this to something with "AttrCapabilities" to be more explicit instead of "blocked"? Currently I needed to go to the model to understand the test. + // TODO: @Bart Can we rename this to something with "AttrCapabilities" to be more explicit instead of "blocked"? Currently I needed to go to the model to understand the test. [Fact] public async Task Cannot_create_resource_with_blocked_attribute() { @@ -464,8 +464,6 @@ public async Task Cannot_create_resource_with_blocked_attribute() responseDocument.Errors[0].Detail.Should().StartWith("Setting the initial value of 'concurrencyToken' is not allowed. - Request body:"); } - // TODO: Deserialization issues because of properties not having setters is very implementation specific, hence something I think we should test in unit tests. - // the other one above with "blocked" attributes seems more suitable for an integration test because there it is about a feature that is transcends just the serializer. [Fact] public async Task Cannot_create_resource_with_readonly_attribute() { @@ -493,6 +491,7 @@ public async Task Cannot_create_resource_with_readonly_attribute() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + // TODO: @Bart apply naming convention. responseDocument.Errors[0].Detail.Should().StartWith("Property 'WorkItemGroup.ConcurrencyToken' is read-only. - Request body:"); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index c8ad4bb7a8..29680ea4a4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -9,7 +9,7 @@ using Xunit; -// TODO: In all assertion blocks, use FirstAsync with a non-null assertion check (except for the two cases where its a OneToOne). +// TODO: @Bart In all assertion blocks, use FirstAsync with a non-null assertion check (except for the two cases where its a OneToOne). namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { public sealed class CreateResourceWithRelationshipTests diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index df98c76632..500df7d027 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -142,6 +142,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } // TODO: Verify if 500 is desired. If so, change test name to reflect that, because deleting resources even if they have a relationship should be possible. + // Two possibilities: + // - Either OnDelete(DeleteBehaviour.SetNull) is the default behaviour, in which case this should not fail + // - Or it is not, in which case it should fail like it does now. + // related: https://stackoverflow.com/questions/33912625/how-to-update-fk-to-null-when-deleting-optional-related-entity [Fact] public async Task Cannot_delete_existing_resource_with_HasMany_relationship() { @@ -206,7 +210,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemsInDatabase.Should().BeEmpty(); - // TODO: Redundant double assertion that tests EF Core rather than JADNC. var workItemTagsInDatabase = await dbContext.WorkItemTags .Where(workItemTag => workItemTag.Item.Id == existingWorkItemTag.Item.Id) .ToListAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 9a7d7cbc58..64e460683e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -102,8 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); - - // TODO: Redundant double assertion that tests EF Core rather than JADNC. + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); userAccountsInDatabase.Should().HaveCount(2); }); @@ -174,7 +173,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Should().HaveCount(1); workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(2).Tag.Id); - // TODO: Redundant double assertion that tests EF Core rather than JADNC. var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); tagsInDatabase.Should().HaveCount(3); }); @@ -231,7 +229,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); - // TODO: Redundant double assertion that tests EF Core rather than JADNC. var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); userAccountsInDatabase.Should().HaveCount(3); }); @@ -577,7 +574,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + // [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_remove_from_unknown_resource_ID_in_url() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 2b5d1d4834..375cda3802 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -215,6 +215,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() { @@ -275,6 +276,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resource() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index c2f0b4c352..5e39f89da9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -124,7 +124,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. + // TODO: @Bart This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasMany_relationship() { @@ -184,7 +184,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: This case is already covered by the Can_replace_HasMany_relationship_with_already_assigned_resources test. + // TODO: @Bart This case is already covered by the Can_replace_HasMany_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasManyThrough_relationship() { @@ -258,6 +258,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: @Bart Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() { @@ -329,6 +330,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: @Bart Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resource() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 41321a8a07..fa62f43706 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -46,7 +46,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "userAccounts", id = existingUserAccount.StringId, attributes = new - { // TODO: can we just inline this? + { }, relationships = new { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 96f009c9a6..f1aec19e40 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources { - // TODO: Tests for mismatch between type in relationship data versus expected clr type based on the relationship being populated./ + // TODO: Tests for mismatch between type in relationship data versus expected clr type based on the relationship being populated. // - POST /primaryResource (HasOne, HasMany and HasManyThrough) // - PATCH /primary resource (HasOne, HasMany and HasManyThrough) public sealed class UpdateToOneRelationshipTests @@ -244,7 +244,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // TODO: Use FirstAsync with non-null assertion. + // TODO: @Bart Use FirstAsync with non-null assertion. var workItemsInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) .ToListAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs index 2e80bae7ae..8719750047 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs @@ -21,6 +21,7 @@ public sealed class WorkItem : Identifiable [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public Guid ConcurrencyToken { get; set; } = Guid.NewGuid(); + // TODO: @Bart Assignee? [HasOne] public UserAccount AssignedTo { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs index 824158aaf8..37dc8cd78c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs @@ -6,7 +6,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { - // TODO: What does a WorkItemGroup represent? I'm not so sure about this being an intuitive model like Article with Authors etc. public sealed class WorkItemGroup : Identifiable { [Attr] From 413479e643ef63678745d3c7ae9190e9439c420f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 14:25:17 +0100 Subject: [PATCH 172/240] fix broken build --- .../Relationships/RemoveFromToManyRelationshipTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 64e460683e..82b90b665a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -574,8 +574,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } - // [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] - [Fact] + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] public async Task Cannot_remove_from_unknown_resource_ID_in_url() { // Arrange From 6d5a332939f9a7de163f0c68b8da161d0c2071f8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 14:25:54 +0100 Subject: [PATCH 173/240] cleanup hooks --- .../Services/CustomArticleService.cs | 2 +- .../Configuration/JsonApiApplicationBuilder.cs | 2 +- .../Hooks/Internal/NullResourceHookExecutor.cs | 4 +--- test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs | 2 +- test/UnitTests/Services/DefaultResourceService_Tests.cs | 5 +++-- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index c82ca17572..50c678dbf3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -25,7 +25,7 @@ public CustomArticleService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor = null) + IResourceHookExecutor hookExecutor) : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 2e0d2f1c9c..ccc1176694 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -248,7 +248,7 @@ private void AddResourceHooks() } else { - _services.AddTransient(_ => NullResourceHookExecutor.Instance); + _services.AddSingleton(); } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs index 1906673409..6b8cb00898 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs @@ -5,10 +5,8 @@ namespace JsonApiDotNetCore.Hooks.Internal { - internal sealed class NullResourceHookExecutor : IResourceHookExecutor + public sealed class NullResourceHookExecutor : IResourceHookExecutor { - internal static NullResourceHookExecutor Instance = new NullResourceHookExecutor(); - public void BeforeRead(ResourcePipeline pipeline, string stringId = null) where TResource : class, IIdentifiable => throw new NotImplementedException(); public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 521f226927..473cd7253e 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -158,7 +158,7 @@ public TestModelService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor = null) + IResourceHookExecutor hookExecutor) : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index 93bd9c2ccb..5a0fd23d7e 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -80,7 +80,8 @@ private JsonApiResourceService GetService() var repositoryAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; - + var resourceHookExecutor = new NullResourceHookExecutor(); + var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); var request = new JsonApiRequest { @@ -92,7 +93,7 @@ private JsonApiResourceService GetService() return new JsonApiResourceService(_repositoryMock.Object, repositoryAccessor, composer, paginationContext, options, NullLoggerFactory.Instance, request, changeTracker, resourceFactory, - targetedFields, resourceContextProvider, NullResourceHookExecutor.Instance); + targetedFields, resourceContextProvider, resourceHookExecutor); } } } From f8e28753407a2b3cb64dbe4d741aeb18dad196c6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 14:40:25 +0100 Subject: [PATCH 174/240] Use public name in error for setting read-only attribute. --- src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs | 7 +++++-- .../Writing/Creating/CreateResourceTests.cs | 5 ++--- .../Writing/Updating/Resources/UpdateResourceTests.cs | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 04f916dfab..f87d379faf 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -87,9 +87,12 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); - responseDocument.Errors[0].Detail.Should().StartWith("Property 'WorkItemGroup.ConcurrencyToken' is read-only. - Request body:"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + responseDocument.Errors[0].Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); } [Fact] From 430b018bd7578e3f945366f992645c5232844a5b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 15:00:49 +0100 Subject: [PATCH 175/240] Hooks: do not execute only if default non-empty implemenation is active (this breaks the pluggable nature of injection). Instead make the do-nothing version really do nothing instead of throwing and call into it unconditionally. --- .../JsonApiApplicationBuilder.cs | 2 +- .../Hooks/Internal/IResourceHookExecutor.cs | 2 +- .../Internal/NeverResourceHookExecutor.cs | 66 +++++++++++ .../Internal/NullResourceHookExecutor.cs | 28 ----- .../Serialization/BaseDeserializer.cs | 1 - .../Serialization/JsonApiReader.cs | 2 +- .../Services/JsonApiResourceService.cs | 108 +++++------------- .../Services/DefaultResourceService_Tests.cs | 2 +- 8 files changed, 98 insertions(+), 113 deletions(-) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs delete mode 100644 src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index ccc1176694..14f916405c 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -248,7 +248,7 @@ private void AddResourceHooks() } else { - _services.AddSingleton(); + _services.AddSingleton(); } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs index 6c27e2569b..56a5cc975f 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Hooks.Internal { /// /// Transient service responsible for executing Resource Hooks as defined - /// in . see methods in + /// in . See methods in /// , and /// for more information. /// diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs new file mode 100644 index 0000000000..d57c442057 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Hooks implementation that does nothing, which is used when is false. + /// + public sealed class NeverResourceHookExecutor : IResourceHookExecutor + { + public void BeforeRead(ResourcePipeline pipeline, string stringId = null) + where TResource : class, IIdentifiable + { + } + + public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public IEnumerable BeforeUpdate(IEnumerable resources, + ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + return resources; + } + + public void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public IEnumerable BeforeCreate(IEnumerable resources, + ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + return resources; + } + + public void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public IEnumerable BeforeDelete(IEnumerable resources, + ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + return resources; + } + + public void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) + where TResource : class, IIdentifiable + { + } + + public IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + return resources; + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs deleted file mode 100644 index 6b8cb00898..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/NullResourceHookExecutor.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - public sealed class NullResourceHookExecutor : IResourceHookExecutor - { - public void BeforeRead(ResourcePipeline pipeline, string stringId = null) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public IEnumerable BeforeUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public IEnumerable BeforeCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public IEnumerable BeforeDelete(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable => throw new NotImplementedException(); - - public IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable => throw new NotImplementedException(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index f87d379faf..f1dcb6968c 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -118,7 +118,6 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio } var resourceProperties = resource.GetType().GetProperties(); - foreach (var attr in relationshipAttributes) { var relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData); diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index ba75e2682e..5e740edc93 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -87,7 +87,7 @@ private bool RequiresRequestBody(string requestMethod) { return true; } - + return requestMethod == HttpMethods.Delete && _request.Kind == EndpointKind.Relationship; } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index a7a8c510ec..e06264380a 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -70,10 +70,7 @@ public virtual async Task> GetAsync() { _traceWriter.LogMethodStart(); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.BeforeRead(ResourcePipeline.Get); - } + _hookExecutor.BeforeRead(ResourcePipeline.Get); if (_options.IncludeTotalResourceCount) { @@ -89,18 +86,13 @@ public virtual async Task> GetAsync() var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); var resources = await _repository.GetAsync(queryLayer); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterRead(resources, ResourcePipeline.Get); - return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); - } - if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) { _paginationContext.IsPageFull = true; } - return resources; + _hookExecutor.AfterRead(resources, ResourcePipeline.Get); + return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); } /// @@ -108,20 +100,12 @@ public virtual async Task GetAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); - } + _hookExecutor.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.PreserveExisting); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetSingle); - return _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetSingle).Single(); - } - - return primaryResource; + _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetSingle); + return _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetSingle).Single(); } /// @@ -130,10 +114,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN _traceWriter.LogMethodStart(new {id, relationshipName}); AssertRelationshipExists(relationshipName); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - } + _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); @@ -151,11 +132,8 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); - } + _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); + primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); var secondaryResource = _request.Relationship.GetValue(primaryResource); @@ -176,10 +154,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh AssertRelationshipExists(relationshipName); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - } + _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); @@ -192,11 +167,8 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); - } + _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); + primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); return _request.Relationship.GetValue(primaryResource); } @@ -215,10 +187,7 @@ public virtual async Task CreateAsync(TResource resource) _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); - if (_hookExecutor is ResourceHookExecutor) - { - resourceFromRequest = _hookExecutor.BeforeCreate(ToList(resourceFromRequest), ResourcePipeline.Post).Single(); - } + resourceFromRequest = _hookExecutor.BeforeCreate(ToList(resourceFromRequest), ResourcePipeline.Post).Single(); try { @@ -231,12 +200,9 @@ public virtual async Task CreateAsync(TResource resource) } var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.PreserveExisting); - - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterCreate(ToList(resourceFromDatabase), ResourcePipeline.Post); - resourceFromDatabase = _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Post).Single(); - } + + _hookExecutor.AfterCreate(ToList(resourceFromDatabase), ResourcePipeline.Post); + resourceFromDatabase = _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Post).Single(); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); @@ -292,10 +258,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - if (_hookExecutor is ResourceHookExecutor) - { - resourceFromRequest = _hookExecutor.BeforeUpdate(ToList(resourceFromRequest), ResourcePipeline.Patch).Single(); - } + resourceFromRequest = _hookExecutor.BeforeUpdate(ToList(resourceFromRequest), ResourcePipeline.Patch).Single(); try { @@ -307,11 +270,8 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); - _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); - } + _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); + _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); @@ -333,15 +293,10 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertHasManyRelationshipValueIsNotNull(secondaryResourceIds); } - TResource primaryResource = null; - - if (_hookExecutor is ResourceHookExecutor) - { - primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - AssertPrimaryResourceExists(primaryResource); - _hookExecutor.BeforeUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); - } - + var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + AssertPrimaryResourceExists(primaryResource); + _hookExecutor.BeforeUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); + try { await _repository.SetRelationshipAsync(id, secondaryResourceIds); @@ -358,7 +313,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, throw; } - if (_hookExecutor is ResourceHookExecutor && primaryResource != null) + if (primaryResource != null) { _hookExecutor.AfterUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); } @@ -369,13 +324,9 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - TResource resource = null; - if (_hookExecutor is ResourceHookExecutor) - { - resource = _resourceFactory.CreateInstance(); - resource.Id = id; - _hookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); - } + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + _hookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); var succeeded = true; @@ -393,10 +344,7 @@ public virtual async Task DeleteAsync(TId id) } finally { - if (_hookExecutor is ResourceHookExecutor) - { - _hookExecutor.AfterDelete(ToList(resource), ResourcePipeline.Delete, succeeded); - } + _hookExecutor.AfterDelete(ToList(resource), ResourcePipeline.Delete, succeeded); } } diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index 5a0fd23d7e..d81cc28da6 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -80,7 +80,7 @@ private JsonApiResourceService GetService() var repositoryAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; - var resourceHookExecutor = new NullResourceHookExecutor(); + var resourceHookExecutor = new NeverResourceHookExecutor(); var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); var request = new JsonApiRequest From 1b9408480988845f051ad6ee369c931a4d0dc364 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 15:06:28 +0100 Subject: [PATCH 176/240] FirstAsync TODOs --- .../Writing/Creating/CreateResourceTests.cs | 44 +++++++++---------- .../Resources/UpdateToOneRelationshipTests.cs | 6 +-- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 90e9c67945..e512d3e915 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -64,8 +64,8 @@ public async Task Sets_location_header_for_created_resource() public async Task Can_create_resource_with_int_ID() { // Arrange - var workItem = _fakers.WorkItem.Generate(); - workItem.DueAt = null; + var newWorkItem = _fakers.WorkItem.Generate(); + newWorkItem.DueAt = null; var requestBody = new { @@ -74,7 +74,7 @@ public async Task Can_create_resource_with_int_ID() type = "workItems", attributes = new { - description = workItem.Description + description = newWorkItem.Description } } }; @@ -89,22 +89,20 @@ public async Task Can_create_resource_with_int_ID() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); - responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); - responseDocument.SingleData.Attributes["dueAt"].Should().Be(workItem.DueAt); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().Be(newWorkItem.DueAt); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - // TODO: @Bart Use FirstAsync with non-null assertion. - var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Description.Should().Be(workItem.Description); - newWorkItemInDatabase.DueAt.Should().Be(workItem.DueAt); + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.DueAt.Should().Be(newWorkItem.DueAt); }); var property = typeof(WorkItem).GetProperty(nameof(Identifiable.Id)); @@ -115,7 +113,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_long_ID() { // Arrange - var userAccount = _fakers.UserAccount.Generate(); + var newUserAccount = _fakers.UserAccount.Generate(); var requestBody = new { @@ -124,8 +122,8 @@ public async Task Can_create_resource_with_long_ID() type = "userAccounts", attributes = new { - firstName = userAccount.FirstName, - lastName = userAccount.LastName + firstName = newUserAccount.FirstName, + lastName = newUserAccount.LastName } } }; @@ -140,22 +138,20 @@ public async Task Can_create_resource_with_long_ID() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("userAccounts"); - responseDocument.SingleData.Attributes["firstName"].Should().Be(userAccount.FirstName); - responseDocument.SingleData.Attributes["lastName"].Should().Be(userAccount.LastName); + responseDocument.SingleData.Attributes["firstName"].Should().Be(newUserAccount.FirstName); + responseDocument.SingleData.Attributes["lastName"].Should().Be(newUserAccount.LastName); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - var newUserAccountId = responseDocument.SingleData.Id; - newUserAccountId.Should().NotBeNullOrEmpty(); + var newUserAccountId = long.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - // TODO: @Bart Use FirstAsync with non-null assertion. - var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == newUserAccountId); - var newUserAccountInDatabase = userAccountsInDatabase.Single(p => p.StringId == newUserAccountId); - newUserAccountInDatabase.FirstName.Should().Be(userAccount.FirstName); - newUserAccountInDatabase.LastName.Should().Be(userAccount.LastName); + userAccountInDatabase.FirstName.Should().Be(newUserAccount.FirstName); + userAccountInDatabase.LastName.Should().Be(newUserAccount.LastName); }); var property = typeof(UserAccount).GetProperty(nameof(Identifiable.Id)); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index f1aec19e40..618538be6a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -244,12 +244,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // TODO: @Bart Use FirstAsync with non-null assertion. - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase2 = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); - var workItemInDatabase2 = workItemsInDatabase.Single(p => p.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); workItemInDatabase2.AssignedTo.Should().NotBeNull(); workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); }); From 50614b8adad6ee17c9312f5a4c5c211fe30648bd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 15:07:52 +0100 Subject: [PATCH 177/240] revert whitespace changes --- .../Creating/CreateResourceWithRelationshipTests.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 29680ea4a4..f84d45dccb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; - // TODO: @Bart In all assertion blocks, use FirstAsync with a non-null assertion check (except for the two cases where its a OneToOne). namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { @@ -77,11 +76,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var groupsInDatabase = await dbContext.Groups .Include(group => group.Color) .ToListAsync(); - + var newGroupInDatabase = groupsInDatabase.Single(p => p.StringId == newGroupId); newGroupInDatabase.Color.Should().NotBeNull(); newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); - + var existingGroupInDatabase = groupsInDatabase.Single(p => p.Id == existingGroup.Id); existingGroupInDatabase.Color.Should().BeNull(); }); @@ -141,7 +140,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var newColorInDatabase = colorsInDatabase.Single(p => p.Id == colorId); newColorInDatabase.Group.Should().NotBeNull(); newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); - + var existingColorInDatabase = colorsInDatabase.Single(p => p.Id == existingColor.Id); existingColorInDatabase.Group.Should().BeNull(); }); @@ -284,7 +283,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() { From 3d41f4536031d0ffd56d88eccba5a735d02bd5a9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 15:13:46 +0100 Subject: [PATCH 178/240] revert unneeded whitespace changes --- .../Relationships/RemoveFromToManyRelationshipTests.cs | 4 ++-- .../Relationships/ReplaceToManyRelationshipTests.cs | 5 ++--- .../Updating/Relationships/UpdateToOneRelationshipTests.cs | 6 +++--- .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 2 +- .../Writing/Updating/Resources/UpdateResourceTests.cs | 2 +- .../IntegrationTests/Writing/UserAccount.cs | 1 - 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 82b90b665a..da63b4bb07 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -102,7 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); - + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); userAccountsInDatabase.Should().HaveCount(2); }); @@ -489,7 +489,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Title.Should().Be("A resource being removed from a relationship does not exist."); responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being removed from relationship 'subscribers' does not exist."); } - + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] public async Task Cannot_remove_unknown_IDs_from_HasManyThrough_relationship() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 375cda3802..61b886f55e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -214,7 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); }); } - + // TODO: Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() @@ -448,7 +448,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } - [Fact] public async Task Cannot_replace_for_missing_ID() { @@ -732,7 +731,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); } - + [Fact] public async Task Can_replace_with_duplicates() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index 61dd0a2098..fbae75e368 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -18,7 +18,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext var colorsInDatabase = await dbContext.RgbColors .Include(rgbColor => rgbColor.Group) .ToListAsync(); - + var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); colorInDatabase1.Group.Should().BeNull(); @@ -143,7 +143,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var groupsInDatabase = await dbContext.Groups .Include(group => group.Color) .ToListAsync(); - + var groupInDatabase1 = groupsInDatabase.Single(p => p.Id == existingGroups[0].Id); groupInDatabase1.Color.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index 5e39f89da9..339f0d61f2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -123,7 +123,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Should().BeEmpty(); }); } - + // TODO: @Bart This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. [Fact] public async Task Can_replace_HasMany_relationship() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index a2cf3e5551..ee6a002cec 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -741,7 +741,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint '/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); } - // TODO: Improve "blocked" naming, see other todo item in update test suite. + // TODO: @Bart Improve "blocked" naming, see other todo item in update test suite. [Fact] public async Task Cannot_update_resource_with_blocked_attribute() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs index 1090e206ff..1e4b60d612 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs @@ -4,7 +4,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { - // TODO: Why not just "User"? That would seem more intuitive to me. public sealed class UserAccount : Identifiable { [Attr] From 55a345cecc116b9040d89e65b1a8354e894912dc Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 15:27:15 +0100 Subject: [PATCH 179/240] Clarified test names for blocked usage --- .../Acceptance/Spec/DisableQueryAttributeTests.cs | 4 ++-- .../IntegrationTests/Filtering/FilterTests.cs | 2 +- .../IntegrationTests/Includes/IncludeTests.cs | 2 +- .../ResourceDefinitionQueryCallbackTests.cs | 2 +- .../IntegrationTests/Sorting/SortTests.cs | 2 +- .../IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs | 2 +- .../IntegrationTests/Writing/Creating/CreateResourceTests.cs | 3 +-- .../Writing/Updating/Resources/UpdateResourceTests.cs | 3 +-- 8 files changed, 9 insertions(+), 11 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs index 82fdfe32d7..42492f3abd 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -19,7 +19,7 @@ public DisableQueryAttributeTests(TestFixture fixture) } [Fact] - public async Task Cannot_Sort_If_Blocked_By_Controller() + public async Task Cannot_Sort_If_Query_String_Parameter_Is_Blocked_By_Controller() { // Arrange var httpMethod = new HttpMethod("GET"); @@ -42,7 +42,7 @@ public async Task Cannot_Sort_If_Blocked_By_Controller() } [Fact] - public async Task Cannot_Use_Custom_Query_Parameter_If_Blocked_By_Controller() + public async Task Cannot_Use_Custom_Query_String_Parameter_If_Blocked_By_Controller() { // Arrange var httpMethod = new HttpMethod("GET"); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs index 43a105b644..b94e3d9b45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -63,7 +63,7 @@ public async Task Cannot_filter_in_unknown_nested_scope() } [Fact] - public async Task Cannot_filter_on_blocked_attribute() + public async Task Cannot_filter_on_attribute_with_blocked_capability() { // Arrange var route = "/api/v1/todoItems?filter=equals(achievedDate,null)"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 97f7c15dd9..ac8d440489 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -720,7 +720,7 @@ public async Task Cannot_include_unknown_nested_relationship() } [Fact] - public async Task Cannot_include_blocked_relationship() + public async Task Cannot_include_relationship_with_blocked_capability() { // Arrange var route = "/api/v1/people?include=unIncludeableItem"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index c442d7c33a..25cd2b201a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -26,7 +26,7 @@ public ResourceDefinitionQueryCallbackTests(IntegrationTestContext(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs index 5ec7869582..d37ff005f4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -663,7 +663,7 @@ public async Task Cannot_sort_in_unknown_nested_scope() } [Fact] - public async Task Cannot_sort_on_blocked_attribute() + public async Task Cannot_sort_on_attribute_with_blocked_capability() { // Arrange var route = "/api/v1/todoItems?sort=achievedDate"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 4ed606caf6..89f446ea1f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -603,7 +603,7 @@ public async Task Cannot_select_in_unknown_nested_scope() } [Fact] - public async Task Cannot_select_blocked_attribute() + public async Task Cannot_select_attribute_with_blocked_capability() { // Arrange var user = _userFaker.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index e512d3e915..67572cd0fd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -429,9 +429,8 @@ public async Task Cannot_create_resource_on_unknown_resource_type_in_url() responseDocument.Should().BeEmpty(); } - // TODO: @Bart Can we rename this to something with "AttrCapabilities" to be more explicit instead of "blocked"? Currently I needed to go to the model to understand the test. [Fact] - public async Task Cannot_create_resource_with_blocked_attribute() + public async Task Cannot_create_resource_attribute_with_blocked_capability() { // Arrange var requestBody = new diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index ee6a002cec..2b61deeeb9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -741,9 +741,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint '/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); } - // TODO: @Bart Improve "blocked" naming, see other todo item in update test suite. [Fact] - public async Task Cannot_update_resource_with_blocked_attribute() + public async Task Cannot_update_resource_attribute_with_blocked_capability() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); From d451fc5b95433f88c40bdd0c8bf336227cf251db Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 16:05:17 +0100 Subject: [PATCH 180/240] Replacement of ToListAsync with FirstAsync in tests --- .../Writing/Creating/CreateResourceTests.cs | 45 +++-- ...reateResourceWithClientGeneratedIdTests.cs | 54 +++--- .../CreateResourceWithRelationshipTests.cs | 178 ++++++++---------- .../Writing/Deleting/DeleteResourceTests.cs | 8 +- .../UpdateToOneRelationshipTests.cs | 13 +- .../Resources/UpdateToOneRelationshipTests.cs | 8 +- .../IntegrationTests/Writing/WriteFakers.cs | 22 +-- 7 files changed, 149 insertions(+), 179 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 67572cd0fd..bb3fd86c5c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -31,7 +31,7 @@ public CreateResourceTests(IntegrationTestContext public async Task Can_create_resource_with_guid_ID() { // Arrange - var group = _fakers.WorkItemGroup.Generate(); + var newGroup = _fakers.WorkItemGroup.Generate(); var requestBody = new { @@ -171,7 +171,7 @@ public async Task Can_create_resource_with_guid_ID() type = "workItemGroups", attributes = new { - name = group.Name + name = newGroup.Name } } }; @@ -186,19 +186,18 @@ public async Task Can_create_resource_with_guid_ID() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); - responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - var newGroupId = responseDocument.SingleData.Id; - newGroupId.Should().NotBeNullOrEmpty(); + var newGroupId = Guid.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var groupsInDatabase = await dbContext.Groups.ToListAsync(); + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == newGroupId); - var newGroupInDatabase = groupsInDatabase.Single(p => p.StringId == newGroupId); - newGroupInDatabase.Name.Should().Be(group.Name); + groupInDatabase.Name.Should().Be(newGroup.Name); }); var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); @@ -238,16 +237,15 @@ public async Task Can_create_resource_without_attributes_or_relationships() responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Description.Should().BeNull(); - newWorkItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Description.Should().BeNull(); + workItemInDatabase.DueAt.Should().BeNull(); }); } @@ -255,7 +253,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_unknown_attribute() { // Arrange - var workItem = _fakers.WorkItem.Generate(); + var newWorkItem = _fakers.WorkItem.Generate(); var requestBody = new { @@ -265,7 +263,7 @@ public async Task Can_create_resource_with_unknown_attribute() attributes = new { doesNotExist = "ignored", - description = workItem.Description + description = newWorkItem.Description } } }; @@ -280,17 +278,16 @@ public async Task Can_create_resource_with_unknown_attribute() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); - responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Description.Should().Be(workItem.Description); + workItemInDatabase.Description.Should().Be(newWorkItem.Description); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 45941063dd..e12b5df6d3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -30,18 +30,18 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext { - var groupsInDatabase = await dbContext.Groups.ToListAsync(); + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == newGroup.Id); - var newGroupInDatabase = groupsInDatabase.Single(p => p.Id == group.Id); - newGroupInDatabase.Name.Should().Be(group.Name); + groupInDatabase.Name.Should().Be(newGroup.Name); }); var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); @@ -75,18 +75,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_with_fieldset() { // Arrange - var group = _fakers.WorkItemGroup.Generate(); - group.Id = Guid.NewGuid(); + var newGroup = _fakers.WorkItemGroup.Generate(); + newGroup.Id = Guid.NewGuid(); var requestBody = new { data = new { type = "workItemGroups", - id = group.StringId, + id = newGroup.StringId, attributes = new { - name = group.Name + name = newGroup.Name } } }; @@ -101,16 +101,16 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); - responseDocument.SingleData.Id.Should().Be(group.StringId); + responseDocument.SingleData.Id.Should().Be(newGroup.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["name"].Should().Be(group.Name); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); await _testContext.RunOnDatabaseAsync(async dbContext => { - var groupsInDatabase = await dbContext.Groups.ToListAsync(); + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == newGroup.Id); - var newGroupInDatabase = groupsInDatabase.Single(p => p.Id == group.Id); - newGroupInDatabase.Name.Should().Be(group.Name); + groupInDatabase.Name.Should().Be(newGroup.Name); }); var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); @@ -121,22 +121,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() { // Arrange - var color = _fakers.RgbColor.Generate(); + var newColor = _fakers.RgbColor.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - var requestBody = new { data = new { type = "rgbColors", - id = color.StringId, + id = newColor.StringId, attributes = new { - displayName = color.DisplayName + displayName = newColor.DisplayName } } }; @@ -153,10 +148,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var colorsInDatabase = await dbContext.RgbColors.ToListAsync(); + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == newColor.Id); - var newColorInDatabase = colorsInDatabase.Single(p => p.Id == color.Id); - newColorInDatabase.DisplayName.Should().Be(color.DisplayName); + colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); }); var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); @@ -174,7 +169,6 @@ public async Task Cannot_create_resource_for_existing_client_generated_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); dbContext.RgbColors.Add(existingColor); await dbContext.SaveChangesAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index f84d45dccb..fd0f77c846 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -// TODO: @Bart In all assertion blocks, use FirstAsync with a non-null assertion check (except for the two cases where its a OneToOne). namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { public sealed class CreateResourceWithRelationshipTests @@ -77,11 +76,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(group => group.Color) .ToListAsync(); - var newGroupInDatabase = groupsInDatabase.Single(p => p.StringId == newGroupId); + var newGroupInDatabase = groupsInDatabase.Single(group => group.StringId == newGroupId); newGroupInDatabase.Color.Should().NotBeNull(); newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); - var existingGroupInDatabase = groupsInDatabase.Single(p => p.Id == existingGroup.Id); + var existingGroupInDatabase = groupsInDatabase.Single(group => group.Id == existingGroup.Id); existingGroupInDatabase.Color.Should().BeNull(); }); } @@ -137,11 +136,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(rgbColor => rgbColor.Group) .ToListAsync(); - var newColorInDatabase = colorsInDatabase.Single(p => p.Id == colorId); + var newColorInDatabase = colorsInDatabase.Single(color => color.Id == colorId); newColorInDatabase.Group.Should().NotBeNull(); newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); - var existingColorInDatabase = colorsInDatabase.Single(p => p.Id == existingColor.Id); + var existingColorInDatabase = colorsInDatabase.Single(color => color.Id == existingColor.Id); existingColorInDatabase.Group.Should().BeNull(); }); } @@ -194,18 +193,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); - newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.AssignedTo.Should().NotBeNull(); + workItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); }); } @@ -214,7 +211,7 @@ public async Task Can_create_resource_with_HasOne_relationship_with_include_and_ { // Arrange var existingUserAccount = _fakers.UserAccount.Generate(); - var workItem = _fakers.WorkItem.Generate(); + var newWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -229,8 +226,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "workItems", attributes = new { - description = workItem.Description, - priority = workItem.Priority + description = newWorkItem.Description, + priority = newWorkItem.Priority }, relationships = new { @@ -256,7 +253,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); @@ -266,20 +263,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Description.Should().Be(workItem.Description); - newWorkItemInDatabase.Priority.Should().Be(workItem.Priority); - newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); - newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); + workItemInDatabase.AssignedTo.Should().NotBeNull(); + workItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); }); } @@ -443,19 +438,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().BeNull(); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Subscribers.Should().HaveCount(2); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); }); } @@ -510,25 +503,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(2); - responseDocument.Included.Should().OnlyContain(p => p.Type == "userAccounts"); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[0].StringId); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[1].StringId); - responseDocument.Included.Should().OnlyContain(p => p.Attributes["firstName"] != null); - responseDocument.Included.Should().OnlyContain(p => p.Attributes["lastName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["lastName"] != null); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Subscribers.Should().HaveCount(2); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[0].Id); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[1].Id); + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); }); } @@ -583,25 +574,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(2); - responseDocument.Included.Should().OnlyContain(p => p.Type == "userAccounts"); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[0].StringId); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingUserAccounts[1].StringId); - responseDocument.Included.Should().OnlyContain(p => p.Attributes.Count == 1); - responseDocument.Included.Should().OnlyContain(p => p.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Subscribers.Should().HaveCount(2); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[0].Id); - newWorkItemInDatabase.Subscribers.Should().ContainSingle(p => p.Id == existingUserAccounts[1].Id); + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); }); } @@ -659,18 +648,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.Subscribers) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Subscribers.Should().HaveCount(1); - newWorkItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); }); } @@ -739,28 +726,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(3); - responseDocument.Included.Should().OnlyContain(p => p.Type == "workTags"); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[0].StringId); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[1].StringId); - responseDocument.Included.Should().ContainSingle(p => p.Id == existingTags[2].StringId); - responseDocument.Included.Should().OnlyContain(p => p.Attributes.Count == 1); - responseDocument.Included.Should().OnlyContain(p => p.Attributes["text"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "workTags"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[1].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[2].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["text"] != null); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.WorkItemTags.Should().HaveCount(3); - newWorkItemInDatabase.WorkItemTags.Should().ContainSingle(p => p.Tag.Id == existingTags[0].Id); - newWorkItemInDatabase.WorkItemTags.Should().ContainSingle(p => p.Tag.Id == existingTags[1].Id); - newWorkItemInDatabase.WorkItemTags.Should().ContainSingle(p => p.Tag.Id == existingTags[2].Id); + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[2].Id); }); } @@ -798,15 +783,14 @@ public async Task Can_create_resource_with_unknown_relationship() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems.ToListAsync(); + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.Description.Should().BeNull(); + workItemInDatabase.Description.Should().BeNull(); }); } @@ -1004,25 +988,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - var newWorkItemId = responseDocument.SingleData.Id; - newWorkItemId.Should().NotBeNullOrEmpty(); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) .Include(workItem => workItem.Subscribers) .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) - .ToListAsync(); - - var newWorkItemInDatabase = workItemsInDatabase.Single(p => p.StringId == newWorkItemId); - newWorkItemInDatabase.AssignedTo.Should().NotBeNull(); - newWorkItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccounts[0].Id); - newWorkItemInDatabase.Subscribers.Should().HaveCount(1); - newWorkItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); - newWorkItemInDatabase.WorkItemTags.Should().HaveCount(1); - newWorkItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.AssignedTo.Should().NotBeNull(); + workItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 500df7d027..7fe586b1ea 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -101,12 +101,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => colorsInDatabase.Should().BeEmpty(); - var groupsInDatabase = await dbContext.Groups - .Where(group => group.Id == existingColor.Group.Id) - .ToListAsync(); + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == existingColor.Group.Id); - groupsInDatabase.Should().HaveCount(1); - groupsInDatabase[0].Color.Should().BeNull(); + groupInDatabase.Color.Should().BeNull(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index fbae75e368..e8c54daba2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -97,10 +97,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(rgbColor => rgbColor.Group) .ToListAsync(); - var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroup.Color.Id); colorInDatabase1.Group.Should().BeNull(); - var colorInDatabase2 = colorsInDatabase.Single(p => p.Id == existingColor.Id); + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingColor.Id); colorInDatabase2.Group.Should().NotBeNull(); colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); }); @@ -144,10 +144,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(group => group.Color) .ToListAsync(); - var groupInDatabase1 = groupsInDatabase.Single(p => p.Id == existingGroups[0].Id); + var groupInDatabase1 = groupsInDatabase.Single(group => group.Id == existingGroups[0].Id); groupInDatabase1.Color.Should().BeNull(); - var groupInDatabase2 = groupsInDatabase.Single(p => p.Id == existingGroups[1].Id); + var groupInDatabase2 = groupsInDatabase.Single(group => group.Id == existingGroups[1].Id); groupInDatabase2.Color.Should().NotBeNull(); groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); @@ -197,11 +197,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var workItemsInDatabase = await dbContext.WorkItems + var workItemInDatabase2 = await dbContext.WorkItems .Include(workItem => workItem.AssignedTo) - .ToListAsync(); + .FirstAsync(workItem => workItem.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); - var workItemInDatabase2 = workItemsInDatabase.Single(p => p.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); workItemInDatabase2.AssignedTo.Should().NotBeNull(); workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 618538be6a..60f78e24f2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -121,10 +121,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(rgbColor => rgbColor.Group) .ToListAsync(); - var colorInDatabase1 = colorsInDatabase.Single(p => p.Id == existingGroup.Color.Id); + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroup.Color.Id); colorInDatabase1.Group.Should().BeNull(); - var colorInDatabase2 = colorsInDatabase.Single(p => p.Id == existingColor.Id); + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingColor.Id); colorInDatabase2.Group.Should().NotBeNull(); colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); }); @@ -180,10 +180,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(group => group.Color) .ToListAsync(); - var groupInDatabase1 = groupsInDatabase.Single(p => p.Id == existingGroups[0].Id); + var groupInDatabase1 = groupsInDatabase.Single(group => group.Id == existingGroups[0].Id); groupInDatabase1.Color.Should().BeNull(); - var groupInDatabase2 = groupsInDatabase.Single(p => p.Id == existingGroups[1].Id); + var groupInDatabase2 = groupsInDatabase.Single(group => group.Id == existingGroups[1].Id); groupInDatabase2.Color.Should().NotBeNull(); groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs index 924701b58d..a5be6362ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -12,33 +12,33 @@ internal class WriteFakers private readonly Lazy> _lazyWorkItemFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Description, f => f.Lorem.Sentence()) - .RuleFor(p => p.DueAt, f => f.Date.Future()) - .RuleFor(p => p.Priority, f => f.PickRandom())); + .RuleFor(workItem => workItem.Description, f => f.Lorem.Sentence()) + .RuleFor(workItem => workItem.DueAt, f => f.Date.Future()) + .RuleFor(workItem => workItem.Priority, f => f.PickRandom())); private readonly Lazy> _lazyWorkTagsFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Text, f => f.Lorem.Word()) - .RuleFor(p => p.IsBuiltIn, f => f.Random.Bool())); + .RuleFor(workTag => workTag.Text, f => f.Lorem.Word()) + .RuleFor(workTag => workTag.IsBuiltIn, f => f.Random.Bool())); private readonly Lazy> _lazyUserAccountFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName())); + .RuleFor(userAccount => userAccount.FirstName, f => f.Name.FirstName()) + .RuleFor(userAccount => userAccount.LastName, f => f.Name.LastName())); private readonly Lazy> _lazyWorkItemGroupFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Name, f => f.Lorem.Word()) - .RuleFor(p => p.IsPublic, f => f.Random.Bool())); + .RuleFor(group => group.Name, f => f.Lorem.Word()) + .RuleFor(group => group.IsPublic, f => f.Random.Bool())); private readonly Lazy> _lazyRgbColorFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(p => p.Id, f => f.Random.Hexadecimal(6)) - .RuleFor(p => p.DisplayName, f => f.Lorem.Word())); + .RuleFor(color => color.Id, f => f.Random.Hexadecimal(6)) + .RuleFor(color => color.DisplayName, f => f.Lorem.Word())); public Faker WorkItem => _lazyWorkItemFaker.Value; public Faker WorkTags => _lazyWorkTagsFaker.Value; From a1bdbc460076fea953a73a72373f564a67212b02 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 16:10:04 +0100 Subject: [PATCH 181/240] Renamed WorkItem.AssignedTo to WorkItem.Assignee --- .../CreateResourceWithRelationshipTests.cs | 40 +++++++++---------- .../AddToToManyRelationshipTests.cs | 4 +- .../RemoveFromToManyRelationshipTests.cs | 8 ++-- .../UpdateToOneRelationshipTests.cs | 36 ++++++++--------- .../Updating/Resources/UpdateResourceTests.cs | 2 +- .../Resources/UpdateToOneRelationshipTests.cs | 30 +++++++------- .../IntegrationTests/Writing/WorkItem.cs | 3 +- .../Writing/WriteDbContext.cs | 2 +- 8 files changed, 62 insertions(+), 63 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index fd0f77c846..3fe4c78797 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -164,7 +164,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "workItems", relationships = new { - assignedTo = new + assignee = new { data = new { @@ -176,7 +176,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?include=assignedTo"; + var route = "/workItems?include=assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -198,11 +198,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == newWorkItemId); - workItemInDatabase.AssignedTo.Should().NotBeNull(); - workItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } @@ -231,7 +231,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }, relationships = new { - assignedTo = new + assignee = new { data = new { @@ -243,7 +243,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?fields=description&include=assignedTo"; + var route = "/workItems?fields=description&include=assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -268,13 +268,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == newWorkItemId); workItemInDatabase.Description.Should().Be(newWorkItem.Description); workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); - workItemInDatabase.AssignedTo.Should().NotBeNull(); - workItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } @@ -289,7 +289,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() type = "workItems", relationships = new { - assignedTo = new + assignee = new { data = new { @@ -311,7 +311,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignedTo' relationship. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); } [Fact] @@ -325,7 +325,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() type = "workItems", relationships = new { - assignedTo = new + assignee = new { data = new { @@ -347,7 +347,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignedTo' relationship. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); } [Fact] @@ -361,7 +361,7 @@ public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() type = "workItems", relationships = new { - assignedTo = new + assignee = new { data = new { @@ -384,7 +384,7 @@ public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignedTo' does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignee' does not exist."); } [Fact] @@ -943,7 +943,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "workItems", relationships = new { - assignedTo = new + assignee = new { data = new { @@ -993,14 +993,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .Include(workItem => workItem.Subscribers) .Include(workItem => workItem.WorkItemTags) .ThenInclude(workItemTag => workItemTag.Tag) .FirstAsync(workItem => workItem.Id == newWorkItemId); - workItemInDatabase.AssignedTo.Should().NotBeNull(); - workItemInDatabase.AssignedTo.Id.Should().Be(existingUserAccounts[0].Id); + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); workItemInDatabase.WorkItemTags.Should().HaveCount(1); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 94788cae7e..7ae73351fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -44,7 +44,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -55,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); responseDocument.Errors[0].Title.Should().Be("Only to-many relationships can be updated through this endpoint."); - responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignedTo' must be a to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index da63b4bb07..41668f4d66 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -24,7 +24,7 @@ public async Task Cannot_remove_from_HasOne_relationship() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -39,12 +39,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "userAccounts", - id = existingWorkItem.AssignedTo.StringId + id = existingWorkItem.Assignee.StringId } } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); @@ -55,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); responseDocument.Errors[0].Title.Should().Be("Only to-many relationships can be updated through this endpoint."); - responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignedTo' must be a to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index e8c54daba2..f38d66b288 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -24,7 +24,7 @@ public async Task Can_clear_ManyToOne_relationship() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -37,7 +37,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = (object)null }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -50,10 +50,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - workItemInDatabase.AssignedTo.Should().BeNull(); + workItemInDatabase.Assignee.Should().BeNull(); }); } @@ -186,7 +186,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingUserAccounts[0].AssignedItems.ElementAt(1).StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingUserAccounts[0].AssignedItems.ElementAt(1).StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -198,11 +198,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase2 = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); - workItemInDatabase2.AssignedTo.Should().NotBeNull(); - workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); + workItemInDatabase2.Assignee.Should().NotBeNull(); + workItemInDatabase2.Assignee.Id.Should().Be(existingUserAccounts[1].Id); }); } @@ -220,7 +220,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = string.Empty; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -254,7 +254,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -289,7 +289,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -323,7 +323,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -358,7 +358,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -369,7 +369,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignee' does not exist."); } [Fact] @@ -394,7 +394,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -426,7 +426,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems/99999999/relationships/assignedTo"; + var route = "/workItems/99999999/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -497,7 +497,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignedTo"; + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -508,7 +508,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); - responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'userAccounts' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/assignedTo', instead of 'rgbColors'."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'userAccounts' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/assignee', instead of 'rgbColors'."); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 2b61deeeb9..66694196a4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -449,7 +449,7 @@ public async Task Update_resource_with_side_effects_hides_relationship_data_in_r { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 60f78e24f2..982fa0fd22 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -27,7 +27,7 @@ public async Task Can_clear_ManyToOne_relationship() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.AssignedTo = _fakers.UserAccount.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -43,7 +43,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, relationships = new { - assignedTo = new + assignee = new { data = (object)null } @@ -64,10 +64,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - workItemInDatabase.AssignedTo.Should().BeNull(); + workItemInDatabase.Assignee.Should().BeNull(); }); } @@ -221,7 +221,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingUserAccounts[0].AssignedItems.ElementAt(1).StringId, relationships = new { - assignedTo = new + assignee = new { data = new { @@ -245,11 +245,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase2 = await dbContext.WorkItems - .Include(workItem => workItem.AssignedTo) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); - workItemInDatabase2.AssignedTo.Should().NotBeNull(); - workItemInDatabase2.AssignedTo.Id.Should().Be(existingUserAccounts[1].Id); + workItemInDatabase2.Assignee.Should().NotBeNull(); + workItemInDatabase2.Assignee.Id.Should().Be(existingUserAccounts[1].Id); }); } @@ -273,7 +273,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, relationships = new { - assignedTo = new + assignee = new { data = new { @@ -295,7 +295,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignedTo' relationship. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); } [Fact] @@ -318,7 +318,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, relationships = new { - assignedTo = new + assignee = new { data = new { @@ -364,7 +364,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, relationships = new { - assignedTo = new + assignee = new { data = new { @@ -386,7 +386,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignedTo' relationship. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); } [Fact] @@ -409,7 +409,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, relationships = new { - assignedTo = new + assignee = new { data = new { @@ -432,7 +432,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignedTo' does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignee' does not exist."); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs index 8719750047..7c9fa34892 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs @@ -21,9 +21,8 @@ public sealed class WorkItem : Identifiable [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public Guid ConcurrencyToken { get; set; } = Guid.NewGuid(); - // TODO: @Bart Assignee? [HasOne] - public UserAccount AssignedTo { get; set; } + public UserAccount Assignee { get; set; } [HasMany] public ISet Subscribers { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs index 147f6d5ac6..d64ba50416 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -19,7 +19,7 @@ public WriteDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() - .HasOne(workItem => workItem.AssignedTo) + .HasOne(workItem => workItem.Assignee) .WithMany(userAccount => userAccount.AssignedItems); builder.Entity() From 258a2bc0874dce647ab3a34eb3123d4ba425e740 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 23:01:15 +0100 Subject: [PATCH 182/240] test improvements --- .../AddToToManyRelationshipTests.cs | 120 +------------- .../RemoveFromToManyRelationshipTests.cs | 124 +-------------- .../ReplaceToManyRelationshipTests.cs | 128 +-------------- .../ReplaceToManyRelationshipTests.cs | 150 +----------------- 4 files changed, 15 insertions(+), 507 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 7ae73351fc..83e6906a52 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -58,119 +58,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); } - [Fact] - public async Task Can_add_to_HasMany_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.Subscribers.Should().HaveCount(3); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); - }); - } - - [Fact] - public async Task Can_add_to_HasManyThrough_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - - var existingTags = _fakers.WorkTags.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(existingWorkItem); - dbContext.WorkTags.AddRange(existingTags); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "workTags", - id = existingTags[0].StringId - }, - new - { - type = "workTags", - id = existingTags[1].StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.WorkItemTags.Should().HaveCount(3); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); - }); - } - [Fact] public async Task Can_add_to_HasMany_relationship_with_already_assigned_resources() { @@ -190,11 +77,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new[] { - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - }, new { type = "userAccounts", @@ -232,7 +114,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resource() + public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resources() { // Arrange var existingWorkItems = _fakers.WorkItem.Generate(2); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 41668f4d66..c03826bc3c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -59,127 +59,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_remove_from_HasMany_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WorkItems.Add(existingWorkItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); - - var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); - userAccountsInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_remove_from_HasManyThrough_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - }, - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - }, - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WorkItems.Add(existingWorkItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "workTags", - id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId - }, - new - { - type = "workTags", - id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.WorkItemTags.Should().HaveCount(1); - workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(2).Tag.Id); - - var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); - tagsInDatabase.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_remove_from_HasMany_relationship_with_unrelated_existing_resource() + public async Task Can_remove_from_HasMany_relationship_with_unassigned_existing_resource() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -235,7 +115,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_remove_from_HasManyThrough_relationship_with_unrelated_existing_resource() + public async Task Can_remove_from_HasManyThrough_relationship_with_unassigned_existing_resource() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 61b886f55e..06b8b23136 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -103,119 +103,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: This case is already covered by the Can_replace_HasMany_relationship_with_already_assigned_resources test. - [Fact] - public async Task Can_replace_HasMany_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); - }); - } - - // TODO: This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. - [Fact] - public async Task Can_replace_HasManyThrough_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - - var existingTags = _fakers.WorkTags.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(existingWorkItem); - dbContext.WorkTags.AddRange(existingTags); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "workTags", - id = existingTags[0].StringId - }, - new - { - type = "workTags", - id = existingTags[1].StringId - } - } - }; - - var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.WorkItemTags.Should().HaveCount(2); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); - }); - } - - // TODO: Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() { @@ -235,11 +122,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new[] { - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - }, new { type = "userAccounts", @@ -269,21 +151,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(workItem => workItem.Subscribers) .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(3); - workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().HaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); }); } - // TODO: Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] - public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resource() + public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resources() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.WorkItemTags = new[] { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, new WorkItemTag { Tag = _fakers.WorkTags.Generate() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index 339f0d61f2..e5c1925e3a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -124,141 +124,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: @Bart This case is already covered by the Can_replace_HasManyThrough_relationship_with_already_assigned_resources test. - [Fact] - public async Task Can_replace_HasMany_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); - - var existingSubscriber = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingSubscriber); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - id = existingWorkItem.StringId, - relationships = new - { - subscribers = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } - } - } - } - }; - - var route = "/workItems/" + existingWorkItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); - }); - } - - // TODO: @Bart This case is already covered by the Can_replace_HasMany_relationship_with_already_assigned_resources test. - [Fact] - public async Task Can_replace_HasManyThrough_relationship() - { - // Arrange - var existingWorkItem = _fakers.WorkItem.Generate(); - existingWorkItem.WorkItemTags = new[] - { - new WorkItemTag - { - Tag = _fakers.WorkTags.Generate() - } - }; - - var existingTags = _fakers.WorkTags.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(existingWorkItem); - dbContext.WorkTags.AddRange(existingTags); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - id = existingWorkItem.StringId, - relationships = new - { - tags = new - { - data = new[] - { - new - { - type = "workTags", - id = existingTags[0].StringId - }, - new - { - type = "workTags", - id = existingTags[1].StringId - } - } - } - } - } - }; - - var route = "/workItems/" + existingWorkItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - - workItemInDatabase.WorkItemTags.Should().HaveCount(2); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); - }); - } - - // TODO: @Bart Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() { @@ -286,11 +151,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new[] { - new - { - type = "userAccounts", - id = existingWorkItem.Subscribers.ElementAt(0).StringId - }, new { type = "userAccounts", @@ -323,21 +183,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(workItem => workItem.Subscribers) .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(3); - workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().HaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); }); } - // TODO: @Bart Currently: [1] => [1,2,3]. Proposed => [1,2] => [1,3,4] [Fact] - public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resource() + public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resources() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.WorkItemTags = new[] { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, new WorkItemTag { Tag = _fakers.WorkTags.Generate() From 756b8ff0b81542a1319650fe199ac8f33f8470f1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 23:28:45 +0100 Subject: [PATCH 183/240] Fail with status Conflict for pre-existing resource ID in POST. --- .../Errors/ResourceAlreadyExistsException.cs | 20 ++++++++++ .../Services/JsonApiResourceService.cs | 40 +++++++++---------- ...reateResourceWithClientGeneratedIdTests.cs | 6 ++- 3 files changed, 43 insertions(+), 23 deletions(-) create mode 100644 src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs diff --git a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs new file mode 100644 index 0000000000..dcc0a0a66b --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when creating a resource with an ID that already exists. + /// + public sealed class ResourceAlreadyExistsException : JsonApiException + { + public ResourceAlreadyExistsException(string resourceId, string resourceType) + : base(new Error(HttpStatusCode.Conflict) + { + Title = "Another resource with the specified ID already exists.", + Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index e06264380a..984697cc18 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -195,6 +195,12 @@ public virtual async Task CreateAsync(TResource resource) } catch (DataStoreUpdateException) { + var existingResource = await TryGetPrimaryResourceById(resource.Id, TopFieldSelection.OnlyIdAttribute); + if (existingResource != null) + { + throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); + } + await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); throw; } @@ -229,7 +235,6 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, catch (DataStoreUpdateException) { var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - AssertPrimaryResourceExists(primaryResource); await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); @@ -294,7 +299,6 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - AssertPrimaryResourceExists(primaryResource); _hookExecutor.BeforeUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); try @@ -303,12 +307,6 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } catch (DataStoreUpdateException) { - if (primaryResource == null) - { - primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - AssertPrimaryResourceExists(primaryResource); - } - await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } @@ -328,18 +326,15 @@ public virtual async Task DeleteAsync(TId id) resource.Id = id; _hookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); - var succeeded = true; - + bool succeeded = false; try { await _repository.DeleteAsync(id); + succeeded = true; } catch (DataStoreUpdateException) { - succeeded = false; resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - AssertPrimaryResourceExists(resource); - throw; } finally @@ -364,14 +359,21 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } catch (DataStoreUpdateException) { - var resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - AssertPrimaryResourceExists(resource); - + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); throw; } } private async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + { + var primaryResource = await TryGetPrimaryResourceById(id, fieldSelection); + + AssertPrimaryResourceExists(primaryResource); + + return primaryResource; + } + + private async Task TryGetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) { var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); primaryLayer.Sort = null; @@ -393,11 +395,7 @@ private async Task GetPrimaryResourceById(TId id, TopFieldSelection f } var primaryResources = await _repository.GetAsync(primaryLayer); - - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); - - return primaryResource; + return primaryResources.SingleOrDefault(); } private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index e12b5df6d3..4fd18980d9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -193,10 +193,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - // TODO: Produce a better error (409:Conflict) and assert on its details here. responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Another resource with the specified ID already exists."); + responseDocument.Errors[0].Detail.Should().Be($"Another resource of type 'rgbColors' with ID '{existingColor.StringId}' already exists."); } } } From e078b7e0989a8bb04c6966f6a7b3c3af56d77d4e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Oct 2020 23:37:23 +0100 Subject: [PATCH 184/240] Fail when trying to update resource ID --- .../Errors/ResourceIdIsReadOnlyException.cs | 19 +++++++++++++++++++ .../Services/JsonApiResourceService.cs | 10 ++++++++++ .../Updating/Resources/UpdateResourceTests.cs | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs new file mode 100644 index 0000000000..17de1485bb --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when trying to change the ID of an existing resource. + /// + public sealed class ResourceIdIsReadOnlyException : JsonApiException + { + public ResourceIdIsReadOnlyException() + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Resource ID is read-only.", + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 984697cc18..0627689ec3 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -255,6 +255,8 @@ public virtual async Task UpdateAsync(TId id, TResource resource) AssertHasManyRelationshipValueIsNotNull(rightResources); } + AssertResourceIdIsNotTargeted(); + var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -285,6 +287,14 @@ public virtual async Task UpdateAsync(TId id, TResource resource) return hasImplicitChanges ? afterResourceFromDatabase : null; } + private void AssertResourceIdIsNotTargeted() + { + if (_targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + { + throw new ResourceIdIsReadOnlyException(); + } + } + /// public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 66694196a4..2fdd391ccb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -847,7 +847,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_change_ID_of_existing_resource() { // Arrange From 13ad9f5c6906c946c84f3cfbc2bccee261c12729 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 30 Oct 2020 12:15:12 +0100 Subject: [PATCH 185/240] test: Can_create_resource_with_duplicate_HasOne_relationship_member to investigate and doucment how parsing to Document works with duplicate dictionary entries --- .../CreateResourceWithRelationshipTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 3fe4c78797..8615546b72 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating @@ -145,6 +146,77 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_create_resource_with_duplicate_HasOne_relationship_member() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + assignee_duplicate = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + }; + + var serializedRequestBody = JsonConvert.SerializeObject(requestBody).Replace("assignee_duplicate", "assignee"); + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, serializedRequestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); + }); + } + [Fact] public async Task Can_create_resource_with_HasOne_relationship_with_include() { From da53b64b5d30be1b8b563a8e37cf3481c541dd26 Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 30 Oct 2020 12:18:04 +0100 Subject: [PATCH 186/240] chore: remove finished todo --- src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index f1dcb6968c..e97cf54daf 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -15,8 +15,6 @@ namespace JsonApiDotNetCore.Serialization { // TODO: check if FK assignments are still required. - // TODO: add test with duplicate dictionary entry in body. - /// /// Abstract base class for deserialization. Deserializes JSON content into s /// and constructs instances of the resource(s) in the document body. From e5eb798c717164c4d0f6b4f127f625fe895a858c Mon Sep 17 00:00:00 2001 From: maurei Date: Fri, 30 Oct 2020 12:53:37 +0100 Subject: [PATCH 187/240] chore: remove setting of FK in base deserializer --- .../Serialization/BaseDeserializer.cs | 33 +------------------ .../IntegrationTests/Writing/RgbColor.cs | 1 + .../Writing/WriteDbContext.cs | 2 +- .../Common/DocumentParserTests.cs | 31 +++++++++-------- 4 files changed, 21 insertions(+), 46 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index e97cf54daf..e369744329 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -14,7 +14,6 @@ namespace JsonApiDotNetCore.Serialization { - // TODO: check if FK assignments are still required. /// /// Abstract base class for deserialization. Deserializes JSON content into s /// and constructs instances of the resource(s) in the document body. @@ -87,6 +86,7 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary p.Name == hasOneRelationship.IdentifiablePropertyName); - - if (foreignKeyProperty != null) - // there is a FK from the current resource pointing to the related object, - // i.e. we're populating the relationship from the dependent side. - SetForeignKey(resource, foreignKeyProperty, hasOneRelationship, relatedId, relationshipType); - SetNavigation(resource, hasOneRelationship, relatedId, relationshipType); // depending on if this base parser is used client-side or server-side, @@ -221,29 +213,6 @@ private void SetHasOneRelationship(IIdentifiable resource, AfterProcessField(resource, hasOneRelationship, relationshipData); } - /// - /// Sets the dependent side of a HasOne relationship, which means that a - /// foreign key also will be populated. - /// - private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasOneAttribute attr, string id, - Type relationshipType) - { - bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null - || foreignKey.PropertyType == typeof(string); - if (id == null && !foreignKeyPropertyIsNullableType) - { - // TODO: FormatException does not look like the right exception type here. - // I would expect such constraints to be checked in the ResourceService layer instead. - - // This happens when a non-optional relationship is deliberately set to null. - // For a server deserializer, it should be mapped to a BadRequest HTTP error code. - throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type."); - } - - var typedId = TypeHelper.ConvertStringIdToTypedId(relationshipType, id, ResourceFactory); - foreignKey.SetValue(resource, typedId); - } - /// /// Sets the principal side of a HasOne relationship, which means no /// foreign key is involved. diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index 1aeca1faa9..d7849d53d6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs index d64ba50416..bf592f38fe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -31,7 +31,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(workItemGroup => workItemGroup.Color) - .WithOne(x => x.Group) + .WithOne(color => color.Group) .HasForeignKey(); builder.Entity() diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index dbc393a15d..8d8e610ad8 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -248,7 +248,7 @@ public void DeserializeRelationships_PopulatedOneToOneDependent_NavigationProper } [Fact] - public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationPropertyAndForeignKeyAreNull() + public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToOneDependents", "principal"); @@ -260,7 +260,6 @@ public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationPropertyAn // Assert Assert.Equal(1, result.Id); Assert.Null(result.Principal); - Assert.Null(result.PrincipalId); } [Fact] @@ -270,12 +269,16 @@ public void DeserializeRelationships_EmptyRequiredOneToOnePrincipal_ThrowsFormat var content = CreateDocumentWithRelationships("oneToOneRequiredDependents", "principal"); var body = JsonConvert.SerializeObject(content); - // Act, assert - Assert.Throws(() => _deserializer.Deserialize(body)); + // Act + var result = (OneToOneRequiredDependent) _deserializer.Deserialize(body); + + // assert + Assert.Equal(1, result.Id); + Assert.Null(result.Principal); } [Fact] - public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationPropertyAndForeignKeyArePopulated() + public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationIsPopulated() { // Arrange var content = CreateDocumentWithRelationships("oneToOneDependents", "principal", "oneToOnePrincipals"); @@ -288,12 +291,11 @@ public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationProper Assert.Equal(1, result.Id); Assert.NotNull(result.Principal); Assert.Equal(10, result.Principal.Id); - Assert.Equal(10, result.PrincipalId); Assert.Null(result.AttributeMember); } [Fact] - public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationAndForeignKeyAreNull() + public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToManyDependents", "principal"); @@ -305,23 +307,27 @@ public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationAndForeig // Assert Assert.Equal(1, result.Id); Assert.Null(result.Principal); - Assert.Null(result.PrincipalId); Assert.Null(result.AttributeMember); } [Fact] - public void DeserializeRelationships_EmptyOneToManyRequiredPrincipal_ThrowsFormatException() + public void DeserializeRelationships_EmptyOneToManyRequiredPrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToMany-requiredDependents", "principal"); var body = JsonConvert.SerializeObject(content); - // Act, assert - Assert.Throws(() => _deserializer.Deserialize(body)); + // Act + var result = (OneToManyRequiredDependent) _deserializer.Deserialize(body); + + // assert + Assert.Equal(1, result.Id); + Assert.Null(result.Principal); + Assert.Null(result.AttributeMember); } [Fact] - public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationAndForeignKeyArePopulated() + public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationIsPopulated() { // Arrange var content = CreateDocumentWithRelationships("oneToManyDependents", "principal", "oneToManyPrincipals"); @@ -334,7 +340,6 @@ public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationAndFo Assert.Equal(1, result.Id); Assert.NotNull(result.Principal); Assert.Equal(10, result.Principal.Id); - Assert.Equal(10, result.PrincipalId); Assert.Null(result.AttributeMember); } From 53f5cd66c3a8676d05b38c393f59aa5ecf1bd3de Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 30 Oct 2020 12:15:13 +0100 Subject: [PATCH 188/240] Wrap hooks execution in facade that fits the needs of resource service. --- .../Services/CustomArticleService.cs | 2 +- .../JsonApiApplicationBuilder.cs | 5 +- .../Internal/IResourceHookExecutorFacade.cs | 58 +++++++ .../Internal/NeverResourceHookExecutor.cs | 66 -------- .../NeverResourceHookExecutorFacade.cs | 95 ++++++++++++ .../Internal/ResourceHookExecutorFacade.cs | 141 ++++++++++++++++++ .../Services/JsonApiResourceService.cs | 110 +++++++------- .../ServiceDiscoveryFacadeTests.cs | 4 +- .../Resources/UpdateToOneRelationshipTests.cs | 21 +++ .../Services/DefaultResourceService_Tests.cs | 2 +- 10 files changed, 379 insertions(+), 125 deletions(-) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs delete mode 100644 src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 50c678dbf3..8bc734243c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -25,7 +25,7 @@ public CustomArticleService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor) + IResourceHookExecutorFacade hookExecutor) : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 14f916405c..d670660d02 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -244,11 +244,12 @@ private void AddResourceHooks() _services.AddScoped(typeof(IResourceHookContainer<>), typeof(ResourceHooksDefinition<>)); _services.AddTransient(); _services.AddTransient(); - _services.AddTransient(); + _services.AddScoped(); + _services.AddScoped(); } else { - _services.AddSingleton(); + _services.AddSingleton(); } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs new file mode 100644 index 0000000000..e553b7948c --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Facade for execution of resource hooks. + /// + public interface IResourceHookExecutorFacade + { + void BeforeReadSingle(TId resourceId, ResourcePipeline pipeline) + where TResource : class, IIdentifiable; + + void AfterReadSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable; + + void BeforeReadMany() + where TResource : class, IIdentifiable; + + void AfterReadMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable; + + void BeforeCreate(TResource resource) + where TResource : class, IIdentifiable; + + void AfterCreate(TResource resource) + where TResource : class, IIdentifiable; + + void BeforeUpdateResource(TResource resource) + where TResource : class, IIdentifiable; + + void AfterUpdateResource(TResource resource) + where TResource : class, IIdentifiable; + + Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + Task BeforeDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + Task AfterDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + void OnReturnSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable; + + IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable; + + object OnReturnRelationship(object resourceOrResources); + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs deleted file mode 100644 index d57c442057..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutor.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Hooks implementation that does nothing, which is used when is false. - /// - public sealed class NeverResourceHookExecutor : IResourceHookExecutor - { - public void BeforeRead(ResourcePipeline pipeline, string stringId = null) - where TResource : class, IIdentifiable - { - } - - public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - } - - public IEnumerable BeforeUpdate(IEnumerable resources, - ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - return resources; - } - - public void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - } - - public IEnumerable BeforeCreate(IEnumerable resources, - ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - return resources; - } - - public void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - } - - public IEnumerable BeforeDelete(IEnumerable resources, - ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - return resources; - } - - public void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) - where TResource : class, IIdentifiable - { - } - - public IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - return resources; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs new file mode 100644 index 0000000000..7f3cc16f94 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Facade for hooks that does nothing, which is used when is false. + /// + public sealed class NeverResourceHookExecutorFacade : IResourceHookExecutorFacade + { + public void BeforeReadSingle(TId resourceId, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public void AfterReadSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public void BeforeReadMany() + where TResource : class, IIdentifiable + { + } + + public void AfterReadMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + } + + public void BeforeCreate(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void AfterCreate(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void BeforeUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void AfterUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + } + + public Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task BeforeDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task AfterDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public void OnReturnSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + return resources; + } + + public object OnReturnRelationship(object resourceOrResources) + { + return resourceOrResources; + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs new file mode 100644 index 0000000000..faf6cc2995 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Facade for hooks that invokes callbacks on , + /// which is used when is true. + /// + internal sealed class ResourceHookExecutorFacade : IResourceHookExecutorFacade + { + private readonly IResourceHookExecutor _resourceHookExecutor; + private readonly IResourceFactory _resourceFactory; + + public ResourceHookExecutorFacade(IResourceHookExecutor resourceHookExecutor, IResourceFactory resourceFactory) + { + _resourceHookExecutor = + resourceHookExecutor ?? throw new ArgumentNullException(nameof(resourceHookExecutor)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + } + + public void BeforeReadSingle(TId resourceId, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + var temporaryResource = _resourceFactory.CreateInstance(); + temporaryResource.Id = resourceId; + + _resourceHookExecutor.BeforeRead(pipeline, temporaryResource.StringId); + } + + public void AfterReadSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterRead(ToList(resource), pipeline); + } + + public void BeforeReadMany() + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeRead(ResourcePipeline.Get); + } + + public void AfterReadMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterRead(resources, ResourcePipeline.Get); + } + + public void BeforeCreate(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeCreate(ToList(resource), ResourcePipeline.Post); + } + + public void AfterCreate(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterCreate(ToList(resource), ResourcePipeline.Post); + } + + public void BeforeUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeUpdate(ToList(resource), ResourcePipeline.Patch); + } + + public void AfterUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterUpdate(ToList(resource), ResourcePipeline.Patch); + } + + public async Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.BeforeUpdate(ToList(resource), ResourcePipeline.PatchRelationship); + } + + public async Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.AfterUpdate(ToList(resource), ResourcePipeline.PatchRelationship); + } + + public async Task BeforeDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); + } + + public async Task AfterDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.AfterDelete(ToList(resource), ResourcePipeline.Delete, true); + } + + public void OnReturnSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.OnReturn(ToList(resource), pipeline); + } + + public IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); + } + + public object OnReturnRelationship(object resourceOrResources) + { + if (resourceOrResources is IEnumerable enumerable) + { + var resources = enumerable.Cast(); + return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.GetRelationship).ToArray(); + } + + if (resourceOrResources is IIdentifiable identifiable) + { + var resources = ToList(identifiable); + return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.GetRelationship).Single(); + } + + return resourceOrResources; + } + + private static List ToList(TResource resource) + { + return new List {resource}; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0627689ec3..1f08c92cc1 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -33,7 +33,7 @@ public class JsonApiResourceService : private readonly IResourceFactory _resourceFactory; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; - private readonly IResourceHookExecutor _hookExecutor; + private readonly IResourceHookExecutorFacade _hookExecutor; public JsonApiResourceService( IResourceRepository repository, @@ -47,7 +47,7 @@ public JsonApiResourceService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor) + IResourceHookExecutorFacade hookExecutor) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -70,7 +70,7 @@ public virtual async Task> GetAsync() { _traceWriter.LogMethodStart(); - _hookExecutor.BeforeRead(ResourcePipeline.Get); + _hookExecutor.BeforeReadMany(); if (_options.IncludeTotalResourceCount) { @@ -91,8 +91,8 @@ public virtual async Task> GetAsync() _paginationContext.IsPageFull = true; } - _hookExecutor.AfterRead(resources, ResourcePipeline.Get); - return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); + _hookExecutor.AfterReadMany(resources); + return _hookExecutor.OnReturnMany(resources); } /// @@ -100,12 +100,14 @@ public virtual async Task GetAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - _hookExecutor.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.PreserveExisting); - _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetSingle); - return _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetSingle).Single(); + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); + _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); + + return primaryResource; } /// @@ -114,7 +116,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN _traceWriter.LogMethodStart(new {id, relationshipName}); AssertRelationshipExists(relationshipName); - _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); @@ -132,18 +134,18 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); - var secondaryResource = _request.Relationship.GetValue(primaryResource); + var secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); - if (secondaryResource is ICollection secondaryResources && - secondaryLayer.Pagination?.PageSize != null && secondaryLayer.Pagination.PageSize.Value == secondaryResources.Count) + if (secondaryResourceOrResources is ICollection secondaryResources && + secondaryLayer.Pagination?.PageSize != null && + secondaryLayer.Pagination.PageSize.Value == secondaryResources.Count) { _paginationContext.IsPageFull = true; } - return secondaryResource; + return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); } /// @@ -154,7 +156,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh AssertRelationshipExists(relationshipName); - _hookExecutor.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); @@ -167,10 +169,11 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - _hookExecutor.AfterRead(ToList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(ToList(primaryResource), ResourcePipeline.GetRelationship).Single(); + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); - return _request.Relationship.GetValue(primaryResource); + var secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); + + return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); } /// @@ -187,7 +190,7 @@ public virtual async Task CreateAsync(TResource resource) _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); - resourceFromRequest = _hookExecutor.BeforeCreate(ToList(resourceFromRequest), ResourcePipeline.Post).Single(); + _hookExecutor.BeforeCreate(resourceFromRequest); try { @@ -207,13 +210,18 @@ public virtual async Task CreateAsync(TResource resource) var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.PreserveExisting); - _hookExecutor.AfterCreate(ToList(resourceFromDatabase), ResourcePipeline.Post); - resourceFromDatabase = _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Post).Single(); + _hookExecutor.AfterCreate(resourceFromDatabase); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? resourceFromDatabase : null; + if (!hasImplicitChanges) + { + return null; + } + + _hookExecutor.OnReturnSingle(resourceFromDatabase, ResourcePipeline.Post); + return resourceFromDatabase; } /// @@ -234,8 +242,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, } catch (DataStoreUpdateException) { - var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; @@ -260,13 +267,13 @@ public virtual async Task UpdateAsync(TId id, TResource resource) var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + _hookExecutor.BeforeUpdateResource(resourceFromRequest); + var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - resourceFromRequest = _hookExecutor.BeforeUpdate(ToList(resourceFromRequest), ResourcePipeline.Patch).Single(); - try { await _repository.UpdateAsync(resourceFromRequest, resourceFromDatabase); @@ -277,14 +284,20 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - _hookExecutor.AfterUpdate(ToList(resourceFromDatabase), ResourcePipeline.Patch); - _hookExecutor.OnReturn(ToList(resourceFromDatabase), ResourcePipeline.Patch); - TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); + + _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterResourceFromDatabase : null; + if (!hasImplicitChanges) + { + return null; + } + + _hookExecutor.OnReturnSingle(afterResourceFromDatabase, ResourcePipeline.Patch); + return afterResourceFromDatabase; } private void AssertResourceIdIsNotTargeted() @@ -308,8 +321,8 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertHasManyRelationshipValueIsNotNull(secondaryResourceIds); } - var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - _hookExecutor.BeforeUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); + await _hookExecutor.BeforeUpdateRelationshipAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); try { @@ -317,14 +330,14 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } catch (DataStoreUpdateException) { + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + throw; } - if (primaryResource != null) - { - _hookExecutor.AfterUpdate(ToList(primaryResource), ResourcePipeline.PatchRelationship); - } + await _hookExecutor.AfterUpdateRelationshipAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); } /// @@ -332,25 +345,21 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - _hookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); + await _hookExecutor.BeforeDeleteAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); - bool succeeded = false; try { await _repository.DeleteAsync(id); - succeeded = true; } catch (DataStoreUpdateException) { - resource = await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); throw; } - finally - { - _hookExecutor.AfterDelete(ToList(resource), ResourcePipeline.Delete, succeeded); - } + + await _hookExecutor.AfterDeleteAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); } /// @@ -554,11 +563,6 @@ private void AssertHasManyRelationshipValueIsNotNull(object secondaryResourceIds } } - private List ToList(TResource resource) - { - return new List { resource }; - } - private enum TopFieldSelection { AllAttributes, @@ -587,7 +591,7 @@ public JsonApiResourceService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor) + IResourceHookExecutorFacade hookExecutor) : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 473cd7253e..6023d898a5 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -40,7 +40,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -158,7 +158,7 @@ public TestModelService( IResourceFactory resourceFactory, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceHookExecutor hookExecutor) + IResourceHookExecutorFacade hookExecutor) : base(repository, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 982fa0fd22..ca8e277b0a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -11,6 +11,27 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resour // TODO: Tests for mismatch between type in relationship data versus expected clr type based on the relationship being populated. // - POST /primaryResource (HasOne, HasMany and HasManyThrough) // - PATCH /primary resource (HasOne, HasMany and HasManyThrough) + // example: + // var requestBody = new + // { + // data = new + // { + // type = "workItems", + // id = 1, + // attributes = new + // { + // }, + // relationships = new + // { + // assignee = new + // { + // type = "rgbColors", // mismatch: expected userAccount (because of assignee) + // id = 2 + // } + // } + // } + // }; + public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> { diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index d81cc28da6..7f00679988 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -80,7 +80,7 @@ private JsonApiResourceService GetService() var repositoryAccessor = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; - var resourceHookExecutor = new NeverResourceHookExecutor(); + var resourceHookExecutor = new NeverResourceHookExecutorFacade(); var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); var request = new JsonApiRequest From 9974b426d4cd14618db3004f25928be6f4506b5d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 30 Oct 2020 16:12:54 +0100 Subject: [PATCH 189/240] renamed test to match existing conventions --- .../CreateResourceWithRelationshipTests.cs | 142 +++++++++--------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 8615546b72..e909d259c3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -146,77 +146,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_create_resource_with_duplicate_HasOne_relationship_member() - { - // Arrange - var existingUserAccounts = _fakers.UserAccount.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.AddRange(existingUserAccounts); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = existingUserAccounts[0].StringId - } - }, - assignee_duplicate = new - { - data = new - { - type = "userAccounts", - id = existingUserAccounts[1].StringId - } - } - } - } - }; - - var serializedRequestBody = JsonConvert.SerializeObject(requestBody).Replace("assignee_duplicate", "assignee"); - - var route = "/workItems?include=assignee"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, serializedRequestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("userAccounts"); - responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); - - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Assignee) - .FirstAsync(workItem => workItem.Id == newWorkItemId); - - workItemInDatabase.Assignee.Should().NotBeNull(); - workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); - }); - } - [Fact] public async Task Can_create_resource_with_HasOne_relationship_with_include() { @@ -866,6 +795,77 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_create_resource_with_duplicate_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + assignee_duplicate = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + }; + + var requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("assignee_duplicate", "assignee"); + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBodyText); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); + }); + } + [Fact] public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() { From 0ad8c8a30eae5d8723af4f074627161997803915 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 30 Oct 2020 16:30:07 +0100 Subject: [PATCH 190/240] cleanup and revert changes --- .../Serialization/BaseDeserializer.cs | 11 +++-------- .../Serialization/Building/ResourceObjectBuilder.cs | 2 ++ .../IntegrationTests/Writing/RgbColor.cs | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index e369744329..efcfa3ad14 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -184,8 +184,7 @@ private ResourceContext GetExistingResourceContext(string publicName) } /// - /// Sets a HasOne relationship on a parsed resource. If present, also - /// populates the foreign key. + /// Sets a HasOne relationship on a parsed resource. /// private void SetHasOneRelationship(IIdentifiable resource, PropertyInfo[] resourceProperties, @@ -206,18 +205,14 @@ private void SetHasOneRelationship(IIdentifiable resource, relationshipType = resourceContext.ResourceType; } - SetNavigation(resource, hasOneRelationship, relatedId, relationshipType); + SetPrincipalSideOfHasOneRelationship(resource, hasOneRelationship, relatedId, relationshipType); // depending on if this base parser is used client-side or server-side, // different additional processing per field needs to be executed. AfterProcessField(resource, hasOneRelationship, relationshipData); } - /// - /// Sets the principal side of a HasOne relationship, which means no - /// foreign key is involved. - /// - private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string relatedId, + private void SetPrincipalSideOfHasOneRelationship(IIdentifiable resource, HasOneAttribute attr, string relatedId, Type relationshipType) { if (relatedId == null) diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index a933fb7e36..bc89b14b2d 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -121,6 +121,8 @@ private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) /// private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable resource) { + // TODO: @Maurits Based on recent changes, do we still need logic related to foreign keys here? + var foreignKey = resource.GetType().GetProperty(attr.IdentifiablePropertyName); if (foreignKey != null && Nullable.GetUnderlyingType(foreignKey.PropertyType) == null) return true; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index d7849d53d6..1aeca1faa9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -1,4 +1,3 @@ -using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; From 68f6a741ec0b70a086d063edda535b713ce73d40 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 10:35:56 +0100 Subject: [PATCH 191/240] feat: draft for using raw queries in complete replacement --- .../JsonApiDotNetCore.csproj | 1 + .../EntityFrameworkCoreRepository.cs | 55 ++++++++++++++++--- .../ReplaceToManyRelationshipTests.cs | 12 +++- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 24253becd3..c290753882 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -24,6 +24,7 @@ + diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7916441db1..feaaeac4c4 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -15,6 +15,7 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -371,21 +372,57 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi } else { - var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. - // Figure out how to trick EF Core into doing this without having to first load all the data (or do it ourselves). - // If we do it ourselves it would probably involve a writing a DeleteWhere extension method. - // var dummy = _resourceFactory.CreateInstance(relationship.RightType); - // dummy.StringId = "999"; - // _dbContext.Entry(dummy).State = EntityState.Unchanged; - // var list = new[] {dummy}; - // relationship.SetValue(resource, TypeHelper.CopyToTypedCollection(list, relationship.Property.PropertyType)); - // navigationEntry.IsLoaded = true; + var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); await navigationEntry.LoadAsync(); + + /* + * + WorkItem.Subscribers = [2,3] --- PATCH --> WorkItem.Subscribers = [1] + + Microsoft.EntityFrameworkCore.Database.Command: Information: Executed DbCommand (3ms) [Parameters=[@p1='1', @p0='1' (Nullable = true), @p3='2', @p2=NULL (DbType = Int32), @p5='3', @p4=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30'] + UPDATE "UserAccounts" SET "WorkItemId" = @p0 WHERE "Id" = @p1; + UPDATE "UserAccounts" SET "WorkItemId" = @p2 + WHERE "Id" = @p3; + UPDATE "UserAccounts" SET "WorkItemId" = @p4 + WHERE "Id" = @p5; + * + * + */ + + + var tableName = GetTableName(relationship.RightType); + var fkColumnName = GetForeignKeyColumnName(relationship, resource); + + await _dbContext.Database.ExecuteSqlRawAsync( + $"UPDATE \"{tableName}\" SET \"{fkColumnName}\" = {{0}} WHERE \"{fkColumnName}\" = {{1}}", null, + resource.Id); + + } } } + private string GetForeignKeyColumnName(RelationshipAttribute relationship, TResource resource) + { + var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); + var fkColumnName = navigationEntry.Metadata.ForeignKey.Properties.First().Name; + + return fkColumnName; + } + + private string GetTableName(Type entityType) + { + var findEntityType = _dbContext.Model.FindEntityType(entityType); + RelationalEntityTypeExtensions.GetTableName(findEntityType); + RelationalEntityTypeExtensions.GetSchema(findEntityType); + var tableNameAnnotation = findEntityType.GetAnnotation("Relational:TableName"); + + var tableName = (string)tableNameAnnotation.Value; + + return tableName; + } + private void FlushFromCache(IIdentifiable resource) { var trackedResource = _dbContext.GetTrackedIdentifiable(resource); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 06b8b23136..48a5b4a70b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -113,9 +113,17 @@ public async Task Can_replace_HasMany_relationship_with_already_assigned_resourc var existingSubscriber = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingWorkItem, existingSubscriber); + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + + // _dbContext.Set().FromRawSql("UPDATE UserAccounts SET WorkItemId = {0} WHERE Id = {1}", null, "2"); + // var commandText = "UPDATE UserAccounts SET WorkItemId = {0} WHERE WorkItemId = {1}"; + // var name = new SqlParameter("@WorkItemId", "Test"); + // var name = new SqlParameter("@NewId", "Test"); await dbContext.SaveChangesAsync(); + + await dbContext.Database.ExecuteSqlRawAsync($"UPDATE \"UserAccounts\" SET \"WorkItemId\" = {{0}} WHERE \"WorkItemId\" = {{1}}", null, 2); + }); var requestBody = new From 0ae089dd04c150e8a9b978eefebe7c53335f22c0 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 10:54:50 +0100 Subject: [PATCH 192/240] chore: remove draft, add todo about complete replacement performance issues --- .../EntityFrameworkCoreRepository.cs | 60 ++++--------------- 1 file changed, 11 insertions(+), 49 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index feaaeac4c4..7551a25db9 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -336,8 +336,18 @@ private TResource CreatePrimaryResourceWithAssignedId(TId id) return resource; } + + + // TODO: This does not perform well. Currently related entities are loaded into memory, + // and when SaveChangesAsync() is called later in the pipeline, the following happens: + // - FKs of records that need to be detached are nulled out one by one, one query each (or the join table entries are deleted one by one in case of many-to-many). + // - FKs records that need to be attached are updated one by one (or join table entries are created one by one). + // Possible approaches forward: + // - Writing raw sql to get around this. + // - Throw when a certain limit of update statements is reached to ensure the developer is aware of these performance issues. + // - Include a 3rd party library that handles batching. /// - /// Prepares a relationship for complete replacement. + /// Performs side-loading of data such that EF Core correctly performs a complete replacement. /// /// /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. @@ -358,9 +368,6 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi { if (relationship is HasManyThroughAttribute hasManyThroughRelationship) { - // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. - // Figure out how to trick EF Core into doing this without having to first load all the data (or do it ourselves). - // If we do it ourselves it would probably involve a writing a DeleteWhere extension method. var throughEntities = await GetFilteredThroughEntities_StaticQueryBuilding(hasManyThroughRelationship, resource.Id, null); hasManyThroughRelationship.ThroughProperty.SetValue(resource, TypeHelper.CopyToTypedCollection(throughEntities, hasManyThroughRelationship.ThroughProperty.PropertyType)); @@ -372,57 +379,12 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi } else { - // TODO: For a complete replacement, all we need is to delete the existing relationships, which is a single query. var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); await navigationEntry.LoadAsync(); - - /* - * - WorkItem.Subscribers = [2,3] --- PATCH --> WorkItem.Subscribers = [1] - - Microsoft.EntityFrameworkCore.Database.Command: Information: Executed DbCommand (3ms) [Parameters=[@p1='1', @p0='1' (Nullable = true), @p3='2', @p2=NULL (DbType = Int32), @p5='3', @p4=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30'] - UPDATE "UserAccounts" SET "WorkItemId" = @p0 WHERE "Id" = @p1; - UPDATE "UserAccounts" SET "WorkItemId" = @p2 - WHERE "Id" = @p3; - UPDATE "UserAccounts" SET "WorkItemId" = @p4 - WHERE "Id" = @p5; - * - * - */ - - - var tableName = GetTableName(relationship.RightType); - var fkColumnName = GetForeignKeyColumnName(relationship, resource); - - await _dbContext.Database.ExecuteSqlRawAsync( - $"UPDATE \"{tableName}\" SET \"{fkColumnName}\" = {{0}} WHERE \"{fkColumnName}\" = {{1}}", null, - resource.Id); - - } } } - private string GetForeignKeyColumnName(RelationshipAttribute relationship, TResource resource) - { - var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); - var fkColumnName = navigationEntry.Metadata.ForeignKey.Properties.First().Name; - - return fkColumnName; - } - - private string GetTableName(Type entityType) - { - var findEntityType = _dbContext.Model.FindEntityType(entityType); - RelationalEntityTypeExtensions.GetTableName(findEntityType); - RelationalEntityTypeExtensions.GetSchema(findEntityType); - var tableNameAnnotation = findEntityType.GetAnnotation("Relational:TableName"); - - var tableName = (string)tableNameAnnotation.Value; - - return tableName; - } - private void FlushFromCache(IIdentifiable resource) { var trackedResource = _dbContext.GetTrackedIdentifiable(resource); From 17f93f6e45baa91b82e8748708667ca5d1fbac10 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 11:19:47 +0100 Subject: [PATCH 193/240] removed duplicate test --- .../CreateResourceWithRelationshipTests.cs | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index e909d259c3..387a247295 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -905,45 +905,6 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); } - [Fact] - public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - subscribers = new - { - data = new[] - { - new - { - type = "userAccounts" - } - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); - } - [Fact] public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() { From eee289ca56be6295edd511bbcd1fe2a949af33b6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 11:23:49 +0100 Subject: [PATCH 194/240] re-orderd tests --- .../CreateResourceWithRelationshipTests.cs | 586 +++++++++--------- 1 file changed, 293 insertions(+), 293 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 387a247295..8df5dd8a4c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -147,14 +147,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_HasOne_relationship_with_include() + public async Task Can_create_resource_with_HasMany_relationship() { // Arrange - var existingUserAccount = _fakers.UserAccount.Generate(); + var existingUserAccounts = _fakers.UserAccount.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.UserAccounts.Add(existingUserAccount); + dbContext.UserAccounts.AddRange(existingUserAccounts); await dbContext.SaveChangesAsync(); }); @@ -165,19 +165,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "workItems", relationships = new { - assignee = new + subscribers = new { - data = new + data = new[] { - type = "userAccounts", - id = existingUserAccount.StringId + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } } } } } }; - var route = "/workItems?include=assignee"; + var route = "/workItems"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -187,32 +195,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("userAccounts"); - responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included.Should().BeNull(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Assignee) + .Include(workItem => workItem.Subscribers) .FirstAsync(workItem => workItem.Id == newWorkItemId); - workItemInDatabase.Assignee.Should().NotBeNull(); - workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); }); } [Fact] - public async Task Can_create_resource_with_HasOne_relationship_with_include_and_primary_fieldset() + public async Task Can_create_resource_with_HasOne_relationship_with_include() { // Arrange var existingUserAccount = _fakers.UserAccount.Generate(); - var newWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -225,11 +228,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "workItems", - attributes = new - { - description = newWorkItem.Description, - priority = newWorkItem.Priority - }, relationships = new { assignee = new @@ -244,7 +242,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?fields=description&include=assignee"; + var route = "/workItems?include=assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -253,9 +251,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(1); @@ -272,124 +267,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == newWorkItemId); - workItemInDatabase.Description.Should().Be(newWorkItem.Description); - workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); workItemInDatabase.Assignee.Should().NotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } [Fact] - public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - id = "12345678" - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts" - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); - } - - [Fact] - public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = "12345678" - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignee' does not exist."); - } - - [Fact] - public async Task Can_create_resource_with_HasMany_relationship() + public async Task Can_create_resource_with_HasMany_relationship_with_include() { // Arrange var existingUserAccounts = _fakers.UserAccount.Generate(2); @@ -427,7 +311,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems"; + var route = "/workItems?include=subscribers"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -437,7 +321,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - responseDocument.Included.Should().BeNull(); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["lastName"] != null); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -448,20 +338,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(workItem => workItem.Id == newWorkItemId); workItemInDatabase.Subscribers.Should().HaveCount(2); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); - workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); }); } [Fact] - public async Task Can_create_resource_with_HasMany_relationship_with_include() + public async Task Can_create_resource_with_HasOne_relationship_with_include_and_primary_fieldset() { // Arrange - var existingUserAccounts = _fakers.UserAccount.Generate(2); + var existingUserAccount = _fakers.UserAccount.Generate(); + var newWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.UserAccounts.AddRange(existingUserAccounts); + dbContext.UserAccounts.Add(existingUserAccount); await dbContext.SaveChangesAsync(); }); @@ -470,29 +361,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "workItems", + attributes = new + { + description = newWorkItem.Description, + priority = newWorkItem.Priority + }, relationships = new { - subscribers = new + assignee = new { - data = new[] + data = new { - new - { - type = "userAccounts", - id = existingUserAccounts[0].StringId - }, - new - { - type = "userAccounts", - id = existingUserAccounts[1].StringId - } + type = "userAccounts", + id = existingUserAccount.StringId } } } } }; - var route = "/workItems?include=subscribers"; + var route = "/workItems?fields=description&include=assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -501,26 +389,29 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(2); - responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); - responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); - responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["lastName"] != null); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.Assignee) .FirstAsync(workItem => workItem.Id == newWorkItemId); - workItemInDatabase.Subscribers.Should().HaveCount(2); - workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); - workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } @@ -596,14 +487,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_duplicate_HasMany_relationships() + public async Task Can_create_resource_with_HasManyThrough_relationship_with_include_and_fieldsets() { // Arrange - var existingUserAccount = _fakers.UserAccount.Generate(); + var existingTags = _fakers.WorkTags.Generate(3); + var workItemToCreate = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.UserAccounts.Add(existingUserAccount); + dbContext.WorkTags.AddRange(existingTags); await dbContext.SaveChangesAsync(); }); @@ -612,29 +504,39 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "workItems", + attributes = new + { + description = workItemToCreate.Description, + priority = workItemToCreate.Priority + }, relationships = new { - subscribers = new + tags = new { data = new[] { new { - type = "userAccounts", - id = existingUserAccount.StringId + type = "workTags", + id = existingTags[0].StringId }, new { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - } + type = "workTags", + id = existingTags[1].StringId + }, + new + { + type = "workTags", + id = existingTags[2].StringId + } + } + } } } }; - var route = "/workItems?include=subscribers"; + var route = "/workItems?fields=priority&include=tags&fields[tags]=text"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -643,68 +545,207 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItemToCreate.Priority.ToString("G")); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("userAccounts"); - responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "workTags"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[1].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[2].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["text"] != null); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) .FirstAsync(workItem => workItem.Id == newWorkItemId); - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[2].Id); }); } [Fact] - public async Task Can_create_resource_with_HasManyThrough_relationship_with_include_and_fieldsets() + public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() { // Arrange - var existingTags = _fakers.WorkTags.Generate(3); - var workItemToCreate = _fakers.WorkItem.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => + var requestBody = new { - dbContext.WorkTags.AddRange(existingTags); - await dbContext.SaveChangesAsync(); - }); + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + id = "12345678" + } + } + } + } + }; + + var route = "/workItems"; + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() + { + // Arrange var requestBody = new { data = new { type = "workItems", - attributes = new - { - description = workItemToCreate.Description, - priority = workItemToCreate.Priority - }, relationships = new { - tags = new + subscribers = new { data = new[] { new { - type = "workTags", - id = existingTags[0].StringId - }, + id = "12345678" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = "12345678" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignee' does not exist."); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() + { + // Arrange + var requestBody = new + { + data = new + { + type = "userAccounts", + relationships = new + { + assignedItems = new + { + data = new[] + { new { - type = "workTags", - id = existingTags[1].StringId + type = "workItems", + id = "12345678" }, new { - type = "workTags", - id = existingTags[2].StringId + type = "workItems", + id = "87654321" } } } @@ -712,42 +753,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?fields=priority&include=tags&fields[tags]=text"; + var route = "/userAccounts"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["priority"].Should().Be(workItemToCreate.Priority.ToString("G")); - - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(3); - responseDocument.Included.Should().OnlyContain(resource => resource.Type == "workTags"); - responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[0].StringId); - responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[1].StringId); - responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[2].StringId); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["text"] != null); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + responseDocument.Errors.Should().HaveCount(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == newWorkItemId); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'workItems' with ID '12345678' being assigned to relationship 'assignedItems' does not exist."); - workItemInDatabase.WorkItemTags.Should().HaveCount(3); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); - workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[2].Id); - }); + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[1].Detail.Should().StartWith("Resource of type 'workItems' with ID '87654321' being assigned to relationship 'assignedItems' does not exist."); } [Fact] @@ -867,68 +889,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() + public async Task Can_create_resource_with_duplicate_HasMany_relationships() { // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - subscribers = new - { - data = new[] - { - new - { - id = "12345678" - } - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + var existingUserAccount = _fakers.UserAccount.Generate(); - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); - } + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); - [Fact] - public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() - { - // Arrange var requestBody = new { data = new { - type = "userAccounts", + type = "workItems", relationships = new { - assignedItems = new + subscribers = new { data = new[] { new { - type = "workItems", - id = "12345678" + type = "userAccounts", + id = existingUserAccount.StringId }, new { - type = "workItems", - id = "87654321" + type = "userAccounts", + id = existingUserAccount.StringId } } } @@ -936,23 +927,32 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() } }; - var route = "/userAccounts"; + var route = "/workItems?include=subscribers"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'workItems' with ID '12345678' being assigned to relationship 'assignedItems' does not exist."); + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[1].Detail.Should().StartWith("Resource of type 'workItems' with ID '87654321' being assigned to relationship 'assignedItems' does not exist."); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + }); } [Fact] From 9804a93583ef4909db165369268bcf12bf27a2e5 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 11:39:27 +0100 Subject: [PATCH 195/240] chore: revert nuget package --- src/JsonApiDotNetCore/JsonApiDotNetCore.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index c290753882..24253becd3 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -24,7 +24,6 @@ - From 418706556e17d0e99eeaf99f4cc6842c516363b1 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 11:57:04 +0100 Subject: [PATCH 196/240] chore: revert --- .../Relationships/ReplaceToManyRelationshipTests.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 48a5b4a70b..039a0aeb20 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -115,15 +115,7 @@ public async Task Can_replace_HasMany_relationship_with_already_assigned_resourc await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.AddRange(existingWorkItem, existingSubscriber); - - // _dbContext.Set().FromRawSql("UPDATE UserAccounts SET WorkItemId = {0} WHERE Id = {1}", null, "2"); - // var commandText = "UPDATE UserAccounts SET WorkItemId = {0} WHERE WorkItemId = {1}"; - // var name = new SqlParameter("@WorkItemId", "Test"); - // var name = new SqlParameter("@NewId", "Test"); - await dbContext.SaveChangesAsync(); - - await dbContext.Database.ExecuteSqlRawAsync($"UPDATE \"UserAccounts\" SET \"WorkItemId\" = {{0}} WHERE \"WorkItemId\" = {{1}}", null, 2); - + await dbContext.SaveChangesAsync(); }); var requestBody = new From ce43c6efefed72b03006377307a4d722e86ee811 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 12:54:11 +0100 Subject: [PATCH 197/240] revert: remove duplicate (it was not) --- .../CreateResourceWithRelationshipTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 8df5dd8a4c..bc0b6d1205 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -722,6 +722,45 @@ public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignee' does not exist."); } + [Fact] + public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + } + [Fact] public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() { From 69038e156546c6439524e58bfc17177d55eab0f6 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 13:06:24 +0100 Subject: [PATCH 198/240] feat: correct deletion of resources when EF Core deletion requires entities to be loaded in memory. --- .../EntityFrameworkCoreRepository.cs | 55 +++++++++---- .../Writing/Deleting/DeleteResourceTests.cs | 78 ++++++++++--------- 2 files changed, 82 insertions(+), 51 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7551a25db9..d78d56f9e0 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -16,6 +16,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Repositories @@ -224,12 +226,43 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - var resource = _dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); + var resource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); + + foreach (var relationship in _resourceGraph.GetRelationships()) + { + if (ShouldLoadRelationshipForSafeDeletion(relationship)) + { + var navigation = GetNavigationEntry(resource, relationship); + await navigation.LoadAsync(); + } + } + + _resourceGraph.GetRelationships(); + _dbContext.Remove(resource); await SaveChangesAsync(); } + /// + /// Loads the data of the relationship if in EF Core it is configured in such a way that loading the related + /// entities into memory is required for successfully executing the selected deletion behavior. + /// + private bool ShouldLoadRelationshipForSafeDeletion(RelationshipAttribute relationship) + { + var navigationMeta = GetNavigationMetadata(relationship); + var clientIsResponsibleForClearingForeignKeys = navigationMeta?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + + var isPrincipalSide = !HasForeignKeyAtLeftSide(relationship); + + return isPrincipalSide && clientIsResponsibleForClearingForeignKeys; + } + + private INavigation GetNavigationMetadata(RelationshipAttribute relationship) + { + return _dbContext.Model.FindEntityType(typeof(TResource)).FindNavigation(relationship.Property.Name); + } + /// public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds) { @@ -296,12 +329,9 @@ private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, T // Ensures the new relationship assignment will not result in entities being tracked more than once. var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + // TODO: Similar to like the EnableCompleteReplacement performance related todo item, we shouldn't have to load the inversely related entity into memory. Clearing any existing relation is enough. if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) { - // TODO: Similar to like the EnableCompleteReplacement todo, we dont actually need to load the inverse relationship. - // all we need to do is clear the inverse relationship such that no uniqueness constraint is violated - // (or have EF core do it efficiently, i.e without having to first fetch the data). - // For one to one it isn't much of a performance issue to because it is a ToOne relationship rather than a large collection. But it would be cleaner to not load it. var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; await entityEntry.Reference(inversePropertyName).LoadAsync(); @@ -319,10 +349,9 @@ private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) { if (relationship is HasOneAttribute) { - var entityType = _dbContext.Model.FindEntityType(typeof(TResource)); - var navigationMetadata = entityType.FindNavigation(relationship.Property.Name); - - return navigationMetadata.IsDependentToPrincipal(); + var navigation = GetNavigationMetadata(relationship); + + return navigation.IsDependentToPrincipal(); } return false; @@ -336,8 +365,6 @@ private TResource CreatePrimaryResourceWithAssignedId(TId id) return resource; } - - // TODO: This does not perform well. Currently related entities are loaded into memory, // and when SaveChangesAsync() is called later in the pipeline, the following happens: // - FKs of records that need to be detached are nulled out one by one, one query each (or the join table entries are deleted one by one in case of many-to-many). @@ -379,7 +406,7 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi } else { - var navigationEntry = GetNavigationEntryForRelationship(relationship, resource); + var navigationEntry = GetNavigationEntry(resource, relationship); await navigationEntry.LoadAsync(); } } @@ -515,7 +542,7 @@ private BinaryExpression GetEqualsCall(HasManyThroughAttribute relationship, Par return Expression.Equal(leftIdProperty, idConstant); } - private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute relationship, TResource resource) + private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) { EntityEntry entityEntry = _dbContext.Entry(resource); @@ -523,8 +550,6 @@ private NavigationEntry GetNavigationEntryForRelationship(RelationshipAttribute { case HasManyAttribute hasManyRelationship: { - // TODO: See if we can get around this by fiddling around with "IsLoaded"? Does EF Core execute one query to delete when saving a complete replacement? - // Consider clearing the entire relationship first rather than fetching it and then letting EF do it inefficiently. return entityEntry.Collection(hasManyRelationship.Property.Name); } case HasOneAttribute hasOneRelationship: diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 7fe586b1ea..459f2daeed 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -9,6 +9,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting { + // TODO: Now that the tests with expected 500 have been converted to the desired behavior, we should consider having + // a (few) test(s) that cover the case of a DeletionBehavior configuration that will lead to a 500. public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> { @@ -45,10 +47,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemsInDatabase = await dbContext.WorkItems - .Where(workItem => workItem.Id == existingWorkItem.Id) - .ToListAsync(); + .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItem.Id); - workItemsInDatabase.Should().BeEmpty(); + workItemsInDatabase.Should().BeNull(); }); } @@ -96,19 +97,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var colorsInDatabase = await dbContext.RgbColors - .Where(color => color.Id == existingColor.Id) - .ToListAsync(); - - colorsInDatabase.Should().BeEmpty(); + .FirstOrDefaultAsync(color => color.Id == existingColor.Id); + + colorsInDatabase.Should().BeNull(); var groupInDatabase = await dbContext.Groups .FirstAsync(group => group.Id == existingColor.Group.Id); - + groupInDatabase.Color.Should().BeNull(); }); } - // TODO: Verify if 500 is desired. If so, change test name to reflect that, because deleting one-to-ones from principal side should work out of the box. [Fact] public async Task Cannot_delete_existing_resource_with_OneToOne_relationship_from_principal_side() { @@ -125,25 +124,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/workItemGroups/" + existingGroup.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); - responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); - responseDocument.Errors[0].Detail.Should().Be("Failed to persist changes in the underlying data store."); + responseDocument.Should().BeEmpty(); - var stackTrace = JsonConvert.SerializeObject(responseDocument.Errors[0].Meta.Data["stackTrace"], Formatting.Indented); - stackTrace.Should().Contain("violates foreign key constraint"); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + + groupsInDatabase.Should().BeNull(); + + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == existingGroup.Color.Id); + + colorInDatabase.Group.Should().BeNull(); + }); } - // TODO: Verify if 500 is desired. If so, change test name to reflect that, because deleting resources even if they have a relationship should be possible. - // Two possibilities: - // - Either OnDelete(DeleteBehaviour.SetNull) is the default behaviour, in which case this should not fail - // - Or it is not, in which case it should fail like it does now. - // related: https://stackoverflow.com/questions/33912625/how-to-update-fk-to-null-when-deleting-optional-related-entity [Fact] public async Task Cannot_delete_existing_resource_with_HasMany_relationship() { @@ -160,18 +161,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/workItems/" + existingWorkItem.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); - responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); - responseDocument.Errors[0].Detail.Should().Be("Failed to persist changes in the underlying data store."); + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Should().BeNull(); - var stackTrace = JsonConvert.SerializeObject(responseDocument.Errors[0].Meta.Data["stackTrace"], Formatting.Indented); - stackTrace.Should().Contain("violates foreign key constraint"); + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + + userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + }); } [Fact] @@ -203,16 +211,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var workItemsInDatabase = await dbContext.WorkItems - .Where(workItem => workItem.Id == existingWorkItemTag.Item.Id) - .ToListAsync(); + .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItemTag.Item.Id); - workItemsInDatabase.Should().BeEmpty(); + workItemsInDatabase.Should().BeNull(); var workItemTagsInDatabase = await dbContext.WorkItemTags - .Where(workItemTag => workItemTag.Item.Id == existingWorkItemTag.Item.Id) - .ToListAsync(); + .FirstOrDefaultAsync(workItemTag => workItemTag.Item.Id == existingWorkItemTag.Item.Id); - workItemTagsInDatabase.Should().BeEmpty(); + workItemTagsInDatabase.Should().BeNull(); }); } } From 6911398f510d9c7808d88d39c19acc5fb1907058 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 13:53:07 +0100 Subject: [PATCH 199/240] refactor: remove use of RelatedIdMapper --- .../Configuration/IRelatedIdMapper.cs | 18 -------- .../Configuration/JsonApiOptions.cs | 5 --- .../Configuration/RelatedIdMapper.cs | 9 ---- .../Configuration/ResourceGraphBuilder.cs | 4 +- .../EntityFrameworkCoreRepository.cs | 6 ++- .../Annotations/HasManyThroughAttribute.cs | 11 ++++- .../Resources/Annotations/HasOneAttribute.cs | 42 +------------------ .../Building/ResourceObjectBuilder.cs | 18 +------- .../Common/ResourceObjectBuilderTests.cs | 22 ---------- 9 files changed, 20 insertions(+), 115 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs diff --git a/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs b/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs deleted file mode 100644 index 71c813d608..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Provides an interface for formatting relationship identifiers from the navigation property name. - /// - public interface IRelatedIdMapper - { - /// - /// Gets the internal property name for the database mapped identifier property. - /// - /// - /// - /// RelatedIdMapper.GetRelatedIdPropertyName("Article"); // returns "ArticleId" - /// - /// - string GetRelatedIdPropertyName(string propertyName); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index b62e86f0af..c999508574 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -76,11 +76,6 @@ public sealed class JsonApiOptions : IJsonApiOptions } }; - /// - /// Provides an interface for formatting relationship ID properties given the navigation property name. - /// - public static IRelatedIdMapper RelatedIdMapper { get; set; } = new RelatedIdMapper(); - // Workaround for https://github.com/dotnet/efcore/issues/21026 internal bool DisableTopPagination { get; set; } internal bool DisableChildrenPagination { get; set; } diff --git a/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs b/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs deleted file mode 100644 index 0d238aeb5b..0000000000 --- a/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCore.Configuration -{ - /// - public sealed class RelatedIdMapper : IRelatedIdMapper - { - /// - public string GetRelatedIdPropertyName(string propertyName) => propertyName + "Id"; - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index cd493d1c5e..be0eb85682 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -191,7 +191,7 @@ private IReadOnlyCollection GetRelationships(Type resourc ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {resourceType}"); // ArticleTag.ArticleId - var leftIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.LeftProperty.Name); + var leftIdPropertyName = hasManyThroughAttribute.LeftIdPropertyName ?? hasManyThroughAttribute.LeftProperty.Name + "Id"; hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName) ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {resourceType} with name {leftIdPropertyName}"); @@ -200,7 +200,7 @@ private IReadOnlyCollection GetRelationships(Type resourc ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); // ArticleTag.TagId - var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name); + var rightIdPropertyName = hasManyThroughAttribute.RightIdPropertyName ?? hasManyThroughAttribute.RightProperty.Name + "Id"; hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName) ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d78d56f9e0..037079bd5f 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -15,11 +15,13 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Logging; + +// TODO: Tests that cover relationship updates with required relationships. All relationships right are currently optional. +// - Setting a required relationship to null +// - ?? namespace JsonApiDotNetCore.Repositories { /// diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index f01bb47fd5..2c93fda137 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -43,6 +43,7 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { + /// /// The name of the join property on the parent resource. /// In the example described above, this would be "ArticleTags". @@ -91,13 +92,21 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; + internal string RightIdPropertyName { get; } + + internal string LeftIdPropertyName { get; } + /// /// Creates a HasMany relationship through a many-to-many join relationship. /// /// The name of the navigation property that will be used to access the join relationship. - public HasManyThroughAttribute(string throughPropertyName) + /// The name of the left id property on the join relationship. The default value is the name of appended with "Id". + /// The name of the right id property on the join relationship. The default value is the name of appended with "Id". + public HasManyThroughAttribute(string throughPropertyName, string leftIdPropertyName = null, string rightIdPropertyName = null) { ThroughPropertyName = throughPropertyName ?? throw new ArgumentNullException(nameof(throughPropertyName)); + LeftIdPropertyName = leftIdPropertyName; + RightIdPropertyName = rightIdPropertyName; } /// diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index a7e822a1cc..f3b948ba45 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -1,5 +1,4 @@ using System; -using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Resources.Annotations { @@ -9,28 +8,6 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class HasOneAttribute : RelationshipAttribute { - private string _identifiablePropertyName; - - /// - /// The foreign key property name. Defaults to "{RelationshipName}Id". - /// - /// - /// Using an alternative foreign key: - /// - /// public class Article : Identifiable - /// { - /// [HasOne(PublicName = "author", IdentifiablePropertyName = nameof(AuthorKey)] - /// public Author Author { get; set; } - /// public int AuthorKey { get; set; } - /// } - /// - /// - public string IdentifiablePropertyName - { - get => _identifiablePropertyName ?? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(Property.Name); - set => _identifiablePropertyName = value; - } - public HasOneAttribute() { Links = LinkTypes.NotConfigured; @@ -41,23 +18,8 @@ public override void SetValue(object resource, object newValue) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - // TODO: Given recent changes, does the following code still need access to foreign keys, or can this be handled by the caller now? - - // If we're deleting the relationship (setting it to null), we set the foreignKey to null. - // We could also set the actual property to null, but then we would first need to load the - // current relationship, which requires an extra query. - - var propertyName = newValue == null ? IdentifiablePropertyName : Property.Name; - var resourceType = resource.GetType(); - - var propertyInfo = resourceType.GetProperty(propertyName); - if (propertyInfo == null) - { - // we can't set the FK to null because there isn't any. - propertyInfo = resourceType.GetProperty(RelationshipPath); - } - - propertyInfo.SetValue(resource, newValue); + var navigationProperty = resource.GetType().GetProperty(RelationshipPath); + navigationProperty!.SetValue(resource, newValue); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index bc89b14b2d..fbf154ec51 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -76,11 +76,11 @@ protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, I private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttribute relationship, IIdentifiable resource) { var relatedResource = (IIdentifiable)relationship.GetValue(resource); - if (relatedResource == null && IsRequiredToOneRelationship(relationship, resource)) - throw new NotSupportedException("Cannot serialize a required to one relationship that is not populated but was included in the set of relationships to be serialized."); if (relatedResource != null) + { return GetResourceIdentifier(relatedResource); + } return null; } @@ -116,20 +116,6 @@ private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) }; } - /// - /// Checks if the to-one relationship is required by checking if the foreign key is nullable. - /// - private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable resource) - { - // TODO: @Maurits Based on recent changes, do we still need logic related to foreign keys here? - - var foreignKey = resource.GetType().GetProperty(attr.IdentifiablePropertyName); - if (foreignKey != null && Nullable.GetUnderlyingType(foreignKey.PropertyType) == null) - return true; - - return false; - } - /// /// Puts the relationships of the resource into the resource object. /// diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs index 96717222b9..3027921810 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -179,27 +179,5 @@ public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKe var ro = (ResourceIdentifierObject)resourceObject.Relationships["principal"].Data; Assert.Equal("10", ro.Id); } - - [Fact] - public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_ThrowsNotSupportedException() - { - // Arrange - var resource = new OneToOneRequiredDependent { Principal = null, PrincipalId = 123 }; - var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); - - // Act & assert - Assert.ThrowsAny(() => _builder.Build(resource, relationships: relationships)); - } - - [Fact] - public void ResourceWithRequiredRelationshipsToResourceObject_EmptyResourceWhileRelationshipIncluded_ThrowsNotSupportedException() - { - // Arrange - var resource = new OneToOneRequiredDependent(); - var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); - - // Act & assert - Assert.ThrowsAny(() => _builder.Build(resource, relationships: relationships)); - } } } From 81ca32501e04a6502ea90d0fc979e3984aa3f284 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 16:36:16 +0100 Subject: [PATCH 200/240] Fixed: attributes must never have optional constructor parameters --- .../Annotations/HasManyThroughAttribute.cs | 24 ++++++++++++------- .../Resources/Annotations/HasOneAttribute.cs | 9 ------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 2c93fda137..6f523ba14b 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -43,7 +43,6 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { - /// /// The name of the join property on the parent resource. /// In the example described above, this would be "ArticleTags". @@ -92,21 +91,30 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; - internal string RightIdPropertyName { get; } + // TODO: Setting these doesn't even work. Use them on ArticleTag and lots of tests start to fail. + // Either make it work or remove the feature. + + /// + /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. + /// Defaults to the name of suffixed with "Id". + /// In the example described above, this would be "ArticleId". + /// + public string LeftIdPropertyName { get; set; } - internal string LeftIdPropertyName { get; } + /// + /// Optional. Can be used to indicate a non-default name for the ID property to the related resource from the through type. + /// Defaults to the name of suffixed with "Id". + /// In the example described above, this would be "TagId". + /// + public string RightIdPropertyName { get; set; } /// /// Creates a HasMany relationship through a many-to-many join relationship. /// /// The name of the navigation property that will be used to access the join relationship. - /// The name of the left id property on the join relationship. The default value is the name of appended with "Id". - /// The name of the right id property on the join relationship. The default value is the name of appended with "Id". - public HasManyThroughAttribute(string throughPropertyName, string leftIdPropertyName = null, string rightIdPropertyName = null) + public HasManyThroughAttribute(string throughPropertyName) { ThroughPropertyName = throughPropertyName ?? throw new ArgumentNullException(nameof(throughPropertyName)); - LeftIdPropertyName = leftIdPropertyName; - RightIdPropertyName = rightIdPropertyName; } /// diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index f3b948ba45..9fd6efa4ef 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -12,14 +12,5 @@ public HasOneAttribute() { Links = LinkTypes.NotConfigured; } - - /// - public override void SetValue(object resource, object newValue) - { - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - var navigationProperty = resource.GetType().GetProperty(RelationshipPath); - navigationProperty!.SetValue(resource, newValue); - } } } From d38e8b2a8a76ab5f6f26143f90761a77d051419f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 16:45:30 +0100 Subject: [PATCH 201/240] added todo and revert whitespace changes --- .../IntegrationTests/Writing/Deleting/DeleteResourceTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 459f2daeed..19c6591fb5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -103,11 +103,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var groupInDatabase = await dbContext.Groups .FirstAsync(group => group.Id == existingColor.Group.Id); - + groupInDatabase.Color.Should().BeNull(); }); } + // TODO: Revert changes to this test, it is supposed to fail like it did. If cascading behavior is desired, users can configure that in EF Core. JADNC should not try to be smart and guess what the user actually wanted. [Fact] public async Task Cannot_delete_existing_resource_with_OneToOne_relationship_from_principal_side() { @@ -145,6 +146,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Revert changes to this test, it is supposed to fail like it did. If cascading behavior is desired, users can configure that in EF Core. JADNC should not try to be smart and guess what the user actually wanted. [Fact] public async Task Cannot_delete_existing_resource_with_HasMany_relationship() { From 7e90f2c983c3160ae11efc45374f4a22c48ea430 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 17:52:20 +0100 Subject: [PATCH 202/240] added missing tests --- .../Serialization/BaseDeserializer.cs | 27 ++- .../Writing/Creating/CreateResourceTests.cs | 2 +- .../CreateResourceWithRelationshipTests.cs | 168 +++++++++++++++++- .../ReplaceToManyRelationshipTests.cs | 49 +++++ .../Resources/UpdateToOneRelationshipTests.cs | 47 +++++ 5 files changed, 277 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index efcfa3ad14..a8d9a40d4e 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; @@ -115,7 +114,6 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio return resource; } - var resourceProperties = resource.GetType().GetProperties(); foreach (var attr in relationshipAttributes) { var relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData); @@ -127,7 +125,7 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio // TODO: Extra validation to make sure there are no list-like data for HasOne relationships and vice versa (Write test) if (attr is HasOneAttribute hasOneAttribute) { - SetHasOneRelationship(resource, resourceProperties, hasOneAttribute, relationshipData); + SetHasOneRelationship(resource, hasOneAttribute, relationshipData); } else { @@ -187,7 +185,6 @@ private ResourceContext GetExistingResourceContext(string publicName) /// Sets a HasOne relationship on a parsed resource. /// private void SetHasOneRelationship(IIdentifiable resource, - PropertyInfo[] resourceProperties, HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) { @@ -201,8 +198,10 @@ private void SetHasOneRelationship(IIdentifiable resource, AssertHasType(relationshipData.SingleData, hasOneRelationship); AssertHasId(relationshipData.SingleData, hasOneRelationship); - var resourceContext = GetExistingResourceContext(relationshipData.SingleData.Type); - relationshipType = resourceContext.ResourceType; + var rightResourceContext = GetExistingResourceContext(relationshipData.SingleData.Type); + AssertRightTypeIsCompatible(rightResourceContext, hasOneRelationship); + + relationshipType = rightResourceContext.ResourceType; } SetPrincipalSideOfHasOneRelationship(resource, hasOneRelationship, relatedId, relationshipType); @@ -254,8 +253,10 @@ private IIdentifiable CreateRightResourceForHasMany(HasManyAttribute hasManyRela AssertHasType(rio, hasManyRelationship); AssertHasId(rio, hasManyRelationship); - var resourceContext = GetExistingResourceContext(rio.Type); - var rightInstance = ResourceFactory.CreateInstance(resourceContext.ResourceType); + var rightResourceContext = GetExistingResourceContext(rio.Type); + AssertRightTypeIsCompatible(rightResourceContext, hasManyRelationship); + + var rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); rightInstance.StringId = rio.Id; return rightInstance; @@ -282,6 +283,16 @@ private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, Rela } } + private void AssertRightTypeIsCompatible(ResourceContext rightResourceContext, RelationshipAttribute relationship) + { + if (!relationship.RightType.IsAssignableFrom(rightResourceContext.ResourceType)) + { + throw new InvalidRequestBodyException("Relationship contains incompatible resource type.", + $"Relationship '{relationship.PublicName}' contains incompatible resource type '{rightResourceContext.PublicName}'.", + null); + } + } + private object ConvertAttrValue(object newValue, Type targetType) { if (newValue is JContainer jObject) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index bb3fd86c5c..e07fa64f82 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -300,7 +300,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() data = new { type = "rgbColors", - id = "#000000", + id = "0A0B0C", attributes = new { name = "Black" diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index bc0b6d1205..016e05775d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -99,7 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string colorId = "#112233"; + string colorId = "0A0B0C"; var requestBody = new { @@ -589,7 +589,7 @@ public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() { data = new { - id = "12345678" + id = 12345678 } } } @@ -627,7 +627,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() { new { - id = "12345678" + id = 12345678 } } } @@ -649,6 +649,83 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); } + [Fact] + public async Task Cannot_create_resource_for_unknown_HasOne_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_HasMany_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + [Fact] public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() { @@ -701,7 +778,7 @@ public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() data = new { type = "userAccounts", - id = "12345678" + id = 12345678 } } } @@ -779,12 +856,12 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() new { type = "workItems", - id = "12345678" + id = 12345678 }, new { type = "workItems", - id = "87654321" + id = 87654321 } } } @@ -811,6 +888,83 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() responseDocument.Errors[1].Detail.Should().StartWith("Resource of type 'workItems' with ID '87654321' being assigned to relationship 'assignedItems' does not exist."); } + [Fact] + public async Task Cannot_create_resource_on_HasOne_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_on_HasMany_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + [Fact] public async Task Can_create_resource_with_unknown_relationship() { @@ -827,7 +981,7 @@ public async Task Can_create_resource_with_unknown_relationship() data = new { type = "doesNotExist", - id = "12345678" + id = 12345678 } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index e5c1925e3a..b27f06614c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -535,6 +535,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); } + [Fact] + public async Task Cannot_replace_on_relationship_type_mismatch() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + [Fact] public async Task Can_replace_with_duplicates() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index ca8e277b0a..0a0dfae8d4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -455,5 +455,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'assignee' does not exist."); } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + } } From 8036daf2c99b993017860a98b3be5faebfa0a02c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 18:45:23 +0100 Subject: [PATCH 203/240] added more missing tests --- .../Services/JsonApiResourceService.cs | 6 ++ .../CreateResourceWithRelationshipTests.cs | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1f08c92cc1..017b15a4bd 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -182,6 +182,12 @@ public virtual async Task CreateAsync(TResource resource) _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); + foreach (var hasManyRelationship in _targetedFields.Relationships.OfType()) + { + var rightResources = hasManyRelationship.GetValue(resource); + AssertHasManyRelationshipValueIsNotNull(rightResources); + } + var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 016e05775d..8bc57ebab8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -1233,5 +1233,71 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); }); } + + [Fact] + public async Task Cannot_create_with_null_data_in_HasMany_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + tags = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } } } From b1493653396d09d67f85783717dcc98eb8c7fbc1 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 19:23:03 +0100 Subject: [PATCH 204/240] review --- .../EntityFrameworkCoreRepository.cs | 34 ++++++++++++------- .../Services/JsonApiResourceService.cs | 2 +- .../Writing/Deleting/DeleteResourceTests.cs | 6 ++-- .../RemoveFromToManyRelationshipTests.cs | 1 - .../ReplaceToManyRelationshipTests.cs | 2 ++ .../Writing/WriteDbContext.cs | 2 +- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 037079bd5f..a21604f37e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -18,10 +18,12 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; - // TODO: Tests that cover relationship updates with required relationships. All relationships right are currently optional. // - Setting a required relationship to null -// - ?? +// - Creating resource with resource +// - One-to-one required / optional => what is the current behavior? +// tangent: +// - How and where to read EF Core metadata when "required-relationship-error" is triggered? namespace JsonApiDotNetCore.Repositories { /// @@ -239,8 +241,6 @@ public virtual async Task DeleteAsync(TId id) } } - _resourceGraph.GetRelationships(); - _dbContext.Remove(resource); await SaveChangesAsync(); @@ -265,6 +265,19 @@ private INavigation GetNavigationMetadata(RelationshipAttribute relationship) return _dbContext.Model.FindEntityType(typeof(TResource)).FindNavigation(relationship.Property.Name); } + /* in service in repo + * Primary resource possible, but inefficient with additional queryside-loading data X no objections, other than complicated to bubble up the error back to service + * in repo (overruled) + * + * Newly assigned set of resources possible, but complicated to access People repo from Article serivce X no objections, other than complicated to bubble up the error back to service + * + * Existing set of resources possible, but violation of concern is. X no objections, other than complicated to bubble up the error back to service + * + * + * + * + * + */ /// public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds) { @@ -277,17 +290,14 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); - var currentRightResourcesCount = rightResources.Count; + // var rightResourcesFromDatabase = GetFilteredThroughEntities_StaticQueryBuilding( ,.., . secondaryResourceIds)); + + rightResources.ExceptWith(secondaryResourceIds); - // TODO: Why has it been reverted to != again? - bool hasChanges = rightResources.Count != currentRightResourcesCount; - if (hasChanges) - { - await ApplyRelationshipUpdate(relationship, primaryResource, rightResources); - await SaveChangesAsync(); - } + await ApplyRelationshipUpdate(relationship, primaryResource, rightResources); + await SaveChangesAsync(); } // TODO: Restore or remove commented-out code. diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1f08c92cc1..7adb9f2bae 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -378,7 +378,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } catch (DataStoreUpdateException) { - await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + // await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); throw; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 459f2daeed..a8e3df8922 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -9,8 +9,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting { - // TODO: Now that the tests with expected 500 have been converted to the desired behavior, we should consider having - // a (few) test(s) that cover the case of a DeletionBehavior configuration that will lead to a 500. public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> { @@ -185,8 +183,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_delete_resource_with_HasManyThrough_relationship() { - // Arrange - var existingWorkItemTag = new WorkItemTag + // Arrange + var existingWorkItemTag = new WorkItemTag { Item = _fakers.WorkItem.Generate(), Tag = _fakers.WorkTags.Generate() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index c03826bc3c..3f4f2a2193 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -459,7 +459,6 @@ public async Task Cannot_remove_from_unknown_resource_ID_in_url() { // Arrange var existingSubscriber = _fakers.UserAccount.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.UserAccounts.Add(existingSubscriber); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 039a0aeb20..9e6754bf1a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -501,6 +501,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } + + // TODO: replacing primary data with new object[0] should still fail, but it doesn't. [Fact] public async Task Cannot_replace_on_unknown_resource_ID_in_url() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs index bf592f38fe..ee9593e09a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -35,7 +35,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasForeignKey(); builder.Entity() - .HasKey(workItemTag => new {workItemTag.ItemId, workItemTag.TagId}); + .HasKey(workItemTag => new { workItemTag.ItemId, workItemTag.TagId}); } } } From c85583cf626335c82506aa0226c0572d59b5b80a Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 19:23:50 +0100 Subject: [PATCH 205/240] review --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 7adb9f2bae..1f08c92cc1 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -378,7 +378,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } catch (DataStoreUpdateException) { - // await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); throw; } } From 87f2012f9480fe94fcb4366c3363a420797f2d3c Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 2 Nov 2020 19:27:12 +0100 Subject: [PATCH 206/240] chore: remove commented out code --- .../EntityFrameworkCoreRepository.cs | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a21604f37e..7b7dd2942a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -291,39 +291,12 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); - // var rightResourcesFromDatabase = GetFilteredThroughEntities_StaticQueryBuilding( ,.., . secondaryResourceIds)); - - rightResources.ExceptWith(secondaryResourceIds); await ApplyRelationshipUpdate(relationship, primaryResource, rightResources); await SaveChangesAsync(); } - // TODO: Restore or remove commented-out code. - /* - // var newRightResources = GetResourcesToAssignForRemoveFromToManyRelationship(existingRightResources, secondaryResourceIds); - // - /// - /// Removes resources from whose ID exists in . - /// - /// - /// - /// - private ICollection GetResourcesToAssignForRemoveFromToManyRelationship( - ISet existingRightResources, ISet resourcesToRemove) - { - var newRightResources = new HashSet(existingRightResources, IdentifiableComparer.Instance); - newRightResources.ExceptWith(resourcesToRemove); - - return newRightResources; - } - */ - private async Task SaveChangesAsync() { try From 0bb759d4c04475d8b33a3b60fbf91dbecda420df Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 19:39:17 +0100 Subject: [PATCH 207/240] remove comments --- .../Writing/Deleting/DeleteResourceTests.cs | 16 ++++++---------- .../Resources/UpdateToOneRelationshipTests.cs | 1 - 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 19c6591fb5..b1e158f500 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -9,8 +9,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting { - // TODO: Now that the tests with expected 500 have been converted to the desired behavior, we should consider having - // a (few) test(s) that cover the case of a DeletionBehavior configuration that will lead to a 500. public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> { @@ -108,9 +106,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: Revert changes to this test, it is supposed to fail like it did. If cascading behavior is desired, users can configure that in EF Core. JADNC should not try to be smart and guess what the user actually wanted. [Fact] - public async Task Cannot_delete_existing_resource_with_OneToOne_relationship_from_principal_side() + public async Task Can_delete_existing_resource_with_OneToOne_relationship_from_principal_side() { // Arrange var existingGroup = _fakers.WorkItemGroup.Generate(); @@ -136,19 +133,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var groupsInDatabase = await dbContext.Groups .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); - + groupsInDatabase.Should().BeNull(); var colorInDatabase = await dbContext.RgbColors .FirstAsync(color => color.Id == existingGroup.Color.Id); - + colorInDatabase.Group.Should().BeNull(); }); } - // TODO: Revert changes to this test, it is supposed to fail like it did. If cascading behavior is desired, users can configure that in EF Core. JADNC should not try to be smart and guess what the user actually wanted. [Fact] - public async Task Cannot_delete_existing_resource_with_HasMany_relationship() + public async Task Can_delete_existing_resource_with_HasMany_relationship() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -179,8 +175,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); - userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); - userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 0a0dfae8d4..4e1e2961c4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -501,6 +501,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); } - } } From 644edba44d7b8d2d3c6b90e97283d84bfa0c371d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 20:07:26 +0100 Subject: [PATCH 208/240] added missing test --- .../Writing/Creating/CreateResourceTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index e07fa64f82..824860e44a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -426,6 +426,32 @@ public async Task Cannot_create_resource_on_unknown_resource_type_in_url() responseDocument.Should().BeEmpty(); } + [Fact] + public async Task Cannot_create_on_resource_type_mismatch_between_url_and_body() + { + // Arrange + var requestBody = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + }; + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'workItems' in POST request body at endpoint '/workItems', instead of 'rgbColors'."); + } + [Fact] public async Task Cannot_create_resource_attribute_with_blocked_capability() { From 80d53b96190c6b8f783a1cc5d203ffc2e4a1e3a6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 20:13:09 +0100 Subject: [PATCH 209/240] Removed unneeded Ignore: property already has NotMapped. --- .../IntegrationTests/Writing/WriteDbContext.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs index bf592f38fe..8e27e4b376 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -26,9 +26,6 @@ protected override void OnModelCreating(ModelBuilder builder) .HasMany(workItem => workItem.Subscribers) .WithOne(); - builder.Entity() - .Ignore(workItemGroup => workItemGroup.ConcurrencyToken); - builder.Entity() .HasOne(workItemGroup => workItemGroup.Color) .WithOne(color => color.Group) From 01cbcad93c53d1cfa217a9f78c7f336a05612dd7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 22:12:22 +0100 Subject: [PATCH 210/240] - Fixed: In POST resource, ?fields= must not influence change tracker (added test that shows the problem) - Fixed: In PATCH resource, never use TopFieldSelection.OnlyIdAttribute because if we do, then we'll never see side effects from database triggers. - Merged tests Cannot_replace_with_unknown_relationship_IDs_in_HasMany_relationship/Cannot_replace_with_unknown_relationship_IDs_in_HasManyThrough_relationship into one (Cannot_replace_with_unknown_relationship_IDs), so this shows how errors from multiple relationships are combined in error response. - Added failing tests for include/fields on PATCH resource endpoint. The right data is returned from the SQL query, but handling the update somehow messes things up in EF Core. Uncommenting repo.Update(...) and doing the change before the test starts makes everything green. So the problem originates in the trickery we do with the change tracker.... --- .../Services/JsonApiResourceService.cs | 32 +-- ...reateResourceWithClientGeneratedIdTests.cs | 41 ++++ .../ReplaceToManyRelationshipTests.cs | 185 ++++++++++++++---- .../Resources/UpdateToOneRelationshipTests.cs | 131 +++++++++++++ 4 files changed, 336 insertions(+), 53 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 017b15a4bd..b661708cfe 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -214,7 +214,7 @@ public virtual async Task CreateAsync(TResource resource) throw; } - var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.PreserveExisting); + var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterCreate(resourceFromDatabase); @@ -275,8 +275,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _hookExecutor.BeforeUpdateResource(resourceFromRequest); - var fieldsToSelect = _targetedFields.Attributes.Any() ? TopFieldSelection.AllAttributes : TopFieldSelection.OnlyIdAttribute; - TResource resourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); + TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); @@ -290,7 +289,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, fieldsToSelect); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); @@ -328,7 +327,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, } await _hookExecutor.BeforeUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); try { @@ -343,7 +342,7 @@ await _hookExecutor.BeforeUpdateRelationshipAsync(id, } await _hookExecutor.AfterUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); } /// @@ -352,7 +351,7 @@ public virtual async Task DeleteAsync(TId id) _traceWriter.LogMethodStart(new {id}); await _hookExecutor.BeforeDeleteAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); try { @@ -365,7 +364,7 @@ await _hookExecutor.BeforeDeleteAsync(id, } await _hookExecutor.AfterDeleteAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.AllAttributes)); + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); } /// @@ -410,14 +409,14 @@ private async Task TryGetPrimaryResourceById(TId id, TopFieldSelectio var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); primaryLayer.Projection = new Dictionary {{idAttribute, null}}; } - else if (fieldSelection == TopFieldSelection.AllAttributes && primaryLayer.Projection != null) + else if (fieldSelection == TopFieldSelection.WithAllAttributes && primaryLayer.Projection != null) { - // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full record. + // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full database row. while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) { primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); } - } + } var primaryResources = await _repository.GetAsync(primaryLayer); return primaryResources.SingleOrDefault(); @@ -571,8 +570,17 @@ private void AssertHasManyRelationshipValueIsNotNull(object secondaryResourceIds private enum TopFieldSelection { - AllAttributes, + /// + /// Preserves included relationships, but selects all resource attributes. + /// + WithAllAttributes, + /// + /// Discards any included relationships and selects only resource ID. + /// OnlyIdAttribute, + /// + /// Preserves the existing selection of attributes and/or relationships. + /// PreserveExisting } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 4fd18980d9..097ba35440 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -158,6 +158,47 @@ await _testContext.RunOnDatabaseAsync(async dbContext => property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); } + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects_with_fieldset() + { + // Arrange + var newColor = _fakers.RgbColor.Generate(); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + attributes = new + { + displayName = newColor.DisplayName + } + } + }; + + var route = "/rgbColors?fields=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == newColor.Id); + + colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + [Fact] public async Task Cannot_create_resource_for_existing_client_generated_ID() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index b27f06614c..87d601caa4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -272,15 +272,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Cannot_replace_for_missing_relationship_type() + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Can_replace_HasMany_relationship_with_include() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.WorkItems.Add(existingWorkItem); + dbContext.AddRange(existingWorkItem, existingUserAccount); await dbContext.SaveChangesAsync(); }); @@ -298,7 +299,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { new { - id = 99999999 + type = "userAccounts", + id = existingUserAccount.StringId } } } @@ -306,22 +308,110 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems/" + existingWorkItem.StringId; + var route = $"/workItems/{existingWorkItem.StringId}?include=subscribers"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Can_replace_HasManyThrough_relationship_with_include_and_fieldsets() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingTag = _fakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=priority&include=tags&fields[tags]=text"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("workTags"); + responseDocument.Included[0].Id.Should().Be(existingTag.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes["text"].Should().Be(existingTag.Text); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + }); } [Fact] - public async Task Cannot_replace_for_unknown_relationship_type() + public async Task Cannot_replace_for_missing_relationship_type() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -346,7 +436,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { new { - type = "doesNotExist", id = 99999999 } } @@ -365,12 +454,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); } [Fact] - public async Task Cannot_replace_for_missing_relationship_ID() + public async Task Cannot_replace_for_unknown_relationship_type() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -395,7 +484,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { new { - type = "userAccounts" + type = "doesNotExist", + id = 99999999 } } } @@ -413,12 +503,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] - public async Task Cannot_replace_with_unknown_relationship_IDs_in_HasMany_relationship() + public async Task Cannot_replace_for_missing_relationship_ID() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -443,13 +533,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { new { - type = "userAccounts", - id = 88888888 - }, - new - { - type = "userAccounts", - id = 99999999 + type = "userAccounts" } } } @@ -463,21 +547,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'subscribers' does not exist."); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); } [Fact] - public async Task Cannot_replace_with_unknown_relationship_IDs_in_HasManyThrough_relationship() + public async Task Cannot_replace_with_unknown_relationship_IDs() { // Arrange var existingWorkItem = _fakers.WorkItem.Generate(); @@ -496,6 +575,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, relationships = new { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }, tags = new { data = new[] @@ -524,15 +619,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.Should().HaveCount(4); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workTags' with ID '88888888' being assigned to relationship 'tags' does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being assigned to relationship 'subscribers' does not exist."); responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[1].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[1].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being assigned to relationship 'subscribers' does not exist."); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[2].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[2].Detail.Should().Be("Resource of type 'workTags' with ID '88888888' being assigned to relationship 'tags' does not exist."); + + responseDocument.Errors[3].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[3].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[3].Detail.Should().Be("Resource of type 'workTags' with ID '99999999' being assigned to relationship 'tags' does not exist."); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 4e1e2961c4..c0f7768708 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -274,6 +274,137 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + public async Task Can_create_relationship_with_include() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_replace_relationship_with_include_and_fieldsets() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=description&include=assignee&fields[assignee]=lastName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + [Fact] public async Task Cannot_create_for_missing_relationship_type() { From 885fb7efa887e65a7c51de15082b7b0de4693746 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 22:24:24 +0100 Subject: [PATCH 211/240] added composite test (attr plus multiple relationships) for patch resource --- .../CreateResourceWithRelationshipTests.cs | 13 ++- .../Updating/Resources/UpdateResourceTests.cs | 109 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs index 8bc57ebab8..7ef1e9ca90 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs @@ -1149,12 +1149,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_multiple_relationship_types() + public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() { // Arrange var existingUserAccounts = _fakers.UserAccount.Generate(2); var existingTag = _fakers.WorkTags.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.UserAccounts.AddRange(existingUserAccounts); @@ -1167,6 +1169,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "workItems", + attributes = new + { + description = newDescription + }, relationships = new { assignee = new @@ -1212,6 +1218,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -1225,10 +1232,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .ThenInclude(workItemTag => workItemTag.Tag) .FirstAsync(workItem => workItem.Id == newWorkItemId); + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.Assignee.Should().NotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().HaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + workItemInDatabase.WorkItemTags.Should().HaveCount(1); workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 2fdd391ccb..6df535e75f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -924,5 +925,113 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); } + + [Fact] + public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + existingWorkItem.WorkItemTags = new List + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingUserAccounts = _fakers.UserAccount.Generate(2); + var existingTag = _fakers.WorkTags.Generate(); + + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingTag); + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription + }, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + }, + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + }); + } + } } From 1477a10d4cace469c8f7aa77b0b9370a616c2de5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 22:56:53 +0100 Subject: [PATCH 212/240] Split of CreateResourceWithRelationshipTests --- .../Writing/Creating/CreateResourceTests.cs | 142 ++++ ...ateResourceWithToManyRelationshipTests.cs} | 674 +----------------- ...reateResourceWithToOneRelationshipTests.cs | 536 ++++++++++++++ .../Updating/Resources/UpdateResourceTests.cs | 44 +- 4 files changed, 733 insertions(+), 663 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/{CreateResourceWithRelationshipTests.cs => CreateResourceWithToManyRelationshipTests.cs} (50%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index 824860e44a..ac4c0d4c89 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -291,6 +291,51 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstOrDefaultAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Should().NotBeNull(); + }); + } + [Fact] public async Task Cannot_create_resource_with_client_generated_ID() { @@ -561,5 +606,102 @@ public async Task Cannot_update_resource_with_incompatible_attribute_value() responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); } + + [Fact] + public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + var existingTag = _fakers.WorkTags.Generate(); + + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + dbContext.WorkTags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newDescription + }, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + }, + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().Be(newDescription); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs similarity index 50% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs index 7ef1e9ca90..45d8785e8d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -11,13 +11,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating { - public sealed class CreateResourceWithRelationshipTests + public sealed class CreateResourceWithToManyRelationshipTests : IClassFixture, WriteDbContext>> { private readonly IntegrationTestContext, WriteDbContext> _testContext; private readonly WriteFakers _fakers = new WriteFakers(); - public CreateResourceWithRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + public CreateResourceWithToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) { _testContext = testContext; @@ -26,128 +26,7 @@ public CreateResourceWithRelationshipTests(IntegrationTestContext - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItemGroups", - relationships = new - { - color = new - { - data = new - { - type = "rgbColors", - id = existingGroup.Color.StringId - } - } - } - } - }; - - var route = "/workItemGroups"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - var newGroupId = responseDocument.SingleData.Id; - newGroupId.Should().NotBeNullOrEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var groupsInDatabase = await dbContext.Groups - .Include(group => group.Color) - .ToListAsync(); - - var newGroupInDatabase = groupsInDatabase.Single(group => group.StringId == newGroupId); - newGroupInDatabase.Color.Should().NotBeNull(); - newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); - - var existingGroupInDatabase = groupsInDatabase.Single(group => group.Id == existingGroup.Id); - existingGroupInDatabase.Color.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_create_resource_with_OneToOne_relationship_from_dependent_side() - { - // Arrange - var existingColor = _fakers.RgbColor.Generate(); - existingColor.Group = _fakers.WorkItemGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RgbColors.Add(existingColor); - await dbContext.SaveChangesAsync(); - }); - - string colorId = "0A0B0C"; - - var requestBody = new - { - data = new - { - type = "rgbColors", - id = colorId, - relationships = new - { - group = new - { - data = new - { - type = "workItemGroups", - id = existingColor.Group.StringId - } - } - } - } - }; - - var route = "/rgbColors"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var colorsInDatabase = await dbContext.RgbColors - .Include(rgbColor => rgbColor.Group) - .ToListAsync(); - - var newColorInDatabase = colorsInDatabase.Single(color => color.Id == colorId); - newColorInDatabase.Group.Should().NotBeNull(); - newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); - - var existingColorInDatabase = colorsInDatabase.Single(color => color.Id == existingColor.Id); - existingColorInDatabase.Group.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_create_resource_with_HasMany_relationship() + public async Task Can_create_HasMany_relationship() { // Arrange var existingUserAccounts = _fakers.UserAccount.Generate(2); @@ -212,68 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_HasOne_relationship_with_include() - { - // Arrange - var existingUserAccount = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.Add(existingUserAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - } - } - }; - - var route = "/workItems?include=assignee"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("userAccounts"); - responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Assignee) - .FirstAsync(workItem => workItem.Id == newWorkItemId); - - workItemInDatabase.Assignee.Should().NotBeNull(); - workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); - }); - } - - [Fact] - public async Task Can_create_resource_with_HasMany_relationship_with_include() + public async Task Can_create_HasMany_relationship_with_include() { // Arrange var existingUserAccounts = _fakers.UserAccount.Generate(2); @@ -344,79 +162,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_HasOne_relationship_with_include_and_primary_fieldset() - { - // Arrange - var existingUserAccount = _fakers.UserAccount.Generate(); - var newWorkItem = _fakers.WorkItem.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.Add(existingUserAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - attributes = new - { - description = newWorkItem.Description, - priority = newWorkItem.Priority - }, - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - } - } - }; - - var route = "/workItems?fields=description&include=assignee"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); - - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("userAccounts"); - responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Assignee) - .FirstAsync(workItem => workItem.Id == newWorkItemId); - - workItemInDatabase.Description.Should().Be(newWorkItem.Description); - workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); - workItemInDatabase.Assignee.Should().NotBeNull(); - workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); - }); - } - - [Fact] - public async Task Can_create_resource_with_HasMany_relationship_with_include_and_secondary_fieldset() + public async Task Can_create_HasMany_relationship_with_include_and_secondary_fieldset() { // Arrange var existingUserAccounts = _fakers.UserAccount.Generate(2); @@ -487,7 +233,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_HasManyThrough_relationship_with_include_and_fieldsets() + public async Task Can_create_HasManyThrough_relationship_with_include_and_fieldsets() { // Arrange var existingTags = _fakers.WorkTags.Generate(3); @@ -575,43 +321,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_resource_for_missing_HasOne_relationship_type() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - id = 12345678 - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() + public async Task Cannot_create_for_missing_relationship_type() { // Arrange var requestBody = new @@ -650,44 +360,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_type() } [Fact] - public async Task Cannot_create_resource_for_unknown_HasOne_relationship_type() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "doesNotExist", - id = 12345678 - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); - } - - [Fact] - public async Task Cannot_create_resource_for_unknown_HasMany_relationship_type() + public async Task Cannot_create_for_unknown_relationship_type() { // Arrange var requestBody = new @@ -727,80 +400,7 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_type() } [Fact] - public async Task Cannot_create_resource_for_missing_HasOne_relationship_ID() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts" - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); - } - - [Fact] - public async Task Cannot_create_resource_for_unknown_HasOne_relationship_ID() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = 12345678 - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignee' does not exist."); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() + public async Task Cannot_create_for_missing_relationship_ID() { // Arrange var requestBody = new @@ -839,7 +439,7 @@ public async Task Cannot_create_resource_for_missing_HasMany_relationship_ID() } [Fact] - public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() + public async Task Cannot_create_for_unknown_relationship_IDs() { // Arrange var requestBody = new @@ -889,44 +489,7 @@ public async Task Cannot_create_resource_for_unknown_HasMany_relationship_IDs() } [Fact] - public async Task Cannot_create_resource_on_HasOne_relationship_type_mismatch() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "rgbColors", - id = "0A0B0C" - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); - } - - [Fact] - public async Task Cannot_create_resource_on_HasMany_relationship_type_mismatch() + public async Task Cannot_create_on_relationship_type_mismatch() { // Arrange var requestBody = new @@ -966,123 +529,7 @@ public async Task Cannot_create_resource_on_HasMany_relationship_type_mismatch() } [Fact] - public async Task Can_create_resource_with_unknown_relationship() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - doesNotExist = new - { - data = new - { - type = "doesNotExist", - id = 12345678 - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .FirstAsync(workItem => workItem.Id == newWorkItemId); - - workItemInDatabase.Description.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_create_resource_with_duplicate_relationship() - { - // Arrange - var existingUserAccounts = _fakers.UserAccount.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.AddRange(existingUserAccounts); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = existingUserAccounts[0].StringId - } - }, - assignee_duplicate = new - { - data = new - { - type = "userAccounts", - id = existingUserAccounts[1].StringId - } - } - } - } - }; - - var requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("assignee_duplicate", "assignee"); - - var route = "/workItems?include=assignee"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBodyText); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("userAccounts"); - responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); - - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Assignee) - .FirstAsync(workItem => workItem.Id == newWorkItemId); - - workItemInDatabase.Assignee.Should().NotBeNull(); - workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); - }); - } - - [Fact] - public async Task Can_create_resource_with_duplicate_HasMany_relationships() + public async Task Can_create_with_duplicates() { // Arrange var existingUserAccount = _fakers.UserAccount.Generate(); @@ -1148,103 +595,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() - { - // Arrange - var existingUserAccounts = _fakers.UserAccount.Generate(2); - var existingTag = _fakers.WorkTags.Generate(); - - var newDescription = _fakers.WorkItem.Generate().Description; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.AddRange(existingUserAccounts); - dbContext.WorkTags.Add(existingTag); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - attributes = new - { - description = newDescription - }, - relationships = new - { - assignee = new - { - data = new - { - type = "userAccounts", - id = existingUserAccounts[0].StringId - } - }, - subscribers = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingUserAccounts[1].StringId - } - } - }, - tags = new - { - data = new[] - { - new - { - type = "workTags", - id = existingTag.StringId - } - } - } - } - } - }; - - var route = "/workItems"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - - var newWorkItemId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var workItemInDatabase = await dbContext.WorkItems - .Include(workItem => workItem.Assignee) - .Include(workItem => workItem.Subscribers) - .Include(workItem => workItem.WorkItemTags) - .ThenInclude(workItemTag => workItemTag.Tag) - .FirstAsync(workItem => workItem.Id == newWorkItemId); - - workItemInDatabase.Description.Should().Be(newDescription); - - workItemInDatabase.Assignee.Should().NotBeNull(); - workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); - - workItemInDatabase.Subscribers.Should().HaveCount(1); - workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); - - workItemInDatabase.WorkItemTags.Should().HaveCount(1); - workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); - }); - } - [Fact] public async Task Cannot_create_with_null_data_in_HasMany_relationship() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs new file mode 100644 index 0000000000..b07e2e4556 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -0,0 +1,536 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +{ + public sealed class CreateResourceWithToOneRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public CreateResourceWithToOneRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingGroup.Color.StringId + } + } + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newGroupId = responseDocument.SingleData.Id; + newGroupId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var newGroupInDatabase = groupsInDatabase.Single(group => group.StringId == newGroupId); + newGroupInDatabase.Color.Should().NotBeNull(); + newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); + + var existingGroupInDatabase = groupsInDatabase.Single(group => group.Id == existingGroup.Id); + existingGroupInDatabase.Color.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + string colorId = "0A0B0C"; + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = colorId, + relationships = new + { + group = new + { + data = new + { + type = "workItemGroups", + id = existingColor.Group.StringId + } + } + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var newColorInDatabase = colorsInDatabase.Single(color => color.Id == colorId); + newColorInDatabase.Group.Should().NotBeNull(); + newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); + + var existingColorInDatabase = colorsInDatabase.Single(color => color.Id == existingColor.Id); + existingColorInDatabase.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_relationship_with_include() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_create_relationship_with_include_and_primary_fieldset() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newWorkItem.Description, + priority = newWorkItem.Priority + }, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems?fields=description&include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_unknown_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A resource being assigned to a relationship does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'userAccounts' with ID '12345678' being assigned to relationship 'assignee' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + + [Fact] + public async Task Can_create_resource_with_duplicate_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + assignee_duplicate = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + }; + + var requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("assignee_duplicate", "assignee"); + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBodyText); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 6df535e75f..640d1d8c27 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -113,6 +113,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_update_resource_with_unknown_relationship() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + [Fact] public async Task Can_partially_update_resource_with_guid_ID() { @@ -1032,6 +1075,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); }); } - } } From 493d7da170d435ff2d1151603052feabbfce7afe Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 23:01:27 +0100 Subject: [PATCH 213/240] removed old test (duplicate) --- .../Spec/ResourceTypeMismatchTests.cs | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs deleted file mode 100644 index c718af2eff..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - // TODO: Move left-over tests in this file. - - public sealed class ResourceTypeMismatchTests : FunctionalTestCollection - { - public ResourceTypeMismatchTests(StandardApplicationFactory factory) : base(factory) { } - - [Fact] - public async Task Posting_Resource_With_Mismatching_Resource_Type_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "people" - } - }); - - // Act - var (body, _) = await Post("/api/v1/todoItems", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); - } - } -} From f0c5c9f5a7d1d7362188988b9b479f38576271bc Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 23:25:11 +0100 Subject: [PATCH 214/240] replaced more old tests --- .../Acceptance/Spec/UpdatingDataTests.cs | 169 ------------------ .../FakeLoggerFactory.cs | 18 +- .../IntegrationTests/Logging/LoggingTests.cs | 69 +++++++ .../ResourceInheritance/InheritanceTests.cs | 62 ++++++- 4 files changed, 145 insertions(+), 173 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs deleted file mode 100644 index 3fe50469c5..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - // TODO: Move left-over tests in this file. - - public sealed class UpdatingDataTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - private readonly Faker _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - private readonly Faker _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - public UpdatingDataTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - FakeLoggerFactory loggerFactory = null; - - testContext.ConfigureLogging(options => - { - loggerFactory = new FakeLoggerFactory(); - - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Trace); - options.AddFilter((category, level) => level == LogLevel.Trace && - (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); - }); - - testContext.ConfigureServicesBeforeStartup(services => - { - if (loggerFactory != null) - { - services.AddSingleton(_ => loggerFactory); - } - }); - } - - [Fact] - public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() - { - // Arrange - var clock = _testContext.Factory.Services.GetRequiredService(); - - SuperUser superUser = null; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - superUser = new SuperUser(dbContext) - { - SecurityLevel = 1337, - UserName = "joe@account.com", - Password = "12345", - LastPasswordChange = clock.UtcNow.LocalDateTime.AddMinutes(-15) - }; - - dbContext.Users.Add(superUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "superUsers", - id = superUser.StringId, - attributes = new Dictionary - { - ["securityLevel"] = 2674, - ["userName"] = "joe@other-domain.com", - ["password"] = "secret" - } - } - }; - - var route = "/api/v1/superUsers/" + superUser.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["securityLevel"].Should().Be(2674); - responseDocument.SingleData.Attributes["userName"].Should().Be("joe@other-domain.com"); - responseDocument.SingleData.Attributes.Should().NotContainKey("password"); - } - - [Fact] - public async Task Can_Patch_Resource_And_HasOne_Relationships() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - dbContext.People.Add(person); - - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["description"] = "Something else" - }, - relationships = new Dictionary - { - ["owner"] = new - { - data = new - { - type = "people", - id = person.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var updated = await dbContext.TodoItems - .Include(t => t.Owner) - .FirstAsync(t => t.Id == todoItem.Id); - - updated.Description.Should().Be("Something else"); - updated.Owner.Id.Should().Be(person.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs index f2c69db509..4fb18a008b 100644 --- a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs @@ -26,9 +26,9 @@ public void Dispose() internal sealed class FakeLogger : ILogger { - private readonly ConcurrentBag<(LogLevel LogLevel, string Text)> _messages = new ConcurrentBag<(LogLevel LogLevel, string Text)>(); + private readonly ConcurrentBag _messages = new ConcurrentBag(); - public IReadOnlyCollection<(LogLevel LogLevel, string Text)> Messages => _messages; + public IReadOnlyCollection Messages => _messages; public bool IsEnabled(LogLevel logLevel) => true; @@ -41,10 +41,22 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except Func formatter) { var message = formatter(state, exception); - _messages.Add((logLevel, message)); + _messages.Add(new LogMessage(logLevel, message)); } public IDisposable BeginScope(TState state) => null; } + + internal sealed class LogMessage + { + public LogLevel LogLevel { get; } + public string Text { get; } + + public LogMessage(LogLevel logLevel, string text) + { + LogLevel = logLevel; + Text = text; + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs new file mode 100644 index 0000000000..61c99f6bb7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class LoggingTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public LoggingTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + FakeLoggerFactory loggerFactory = null; + + testContext.ConfigureLogging(options => + { + loggerFactory = new FakeLoggerFactory(); + + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, level) => level == LogLevel.Trace && + (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); + }); + + testContext.ConfigureServicesBeforeStartup(services => + { + if (loggerFactory != null) + { + services.AddSingleton(_ => loggerFactory); + } + }); + } + + [Fact] + public async Task Logs_request_body_on_error() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + // Arrange + var requestBody = "{ \"data\" {"; + + var route = "/api/v1/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + loggerFactory.Logger.Messages.Should().HaveCount(2); + loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Received request at ") && message.Text.Contains("with body:")); + loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Sending 422 response for request at ") && message.Text.Contains("Failed to deserialize request body.")); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 0a498da345..f2c2c6001e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -124,7 +124,67 @@ await _testContext.RunOnDatabaseAsync(async dbContext => manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); }); } - + + [Fact] + public async Task Can_update_resource_through_primary_endpoint() + { + // Arrange + var existingMan = new Man + { + FamilyName = "Smith", + IsRetired = false, + HasBeard = true + }; + + var newMan = new Man + { + FamilyName = "Jackson", + IsRetired = true, + HasBeard = false + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Men.Add(existingMan); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "men", + id = existingMan.StringId, + attributes = new + { + familyName = newMan.FamilyName, + isRetired = newMan.IsRetired, + hasBeard = newMan.HasBeard + } + } + }; + + var route = "/men/" + existingMan.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var manInDatabase = await dbContext.Men + .FirstAsync(man => man.Id == existingMan.Id); + + manInDatabase.FamilyName.Should().Be(newMan.FamilyName); + manInDatabase.IsRetired.Should().Be(newMan.IsRetired); + manInDatabase.HasBeard.Should().Be(newMan.HasBeard); + }); + } + [Fact] public async Task Can_update_resource_with_ToOne_relationship_through_relationship_endpoint() { From 34ac2236f4858ee96fa5568154b985a2fba3bd51 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 2 Nov 2020 23:31:16 +0100 Subject: [PATCH 215/240] Removed TODO (these are implemented by Cannot_replace_on_relationship_type_mismatch tests) --- .../Resources/UpdateToOneRelationshipTests.cs | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index c0f7768708..62e590f776 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -8,30 +8,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources { - // TODO: Tests for mismatch between type in relationship data versus expected clr type based on the relationship being populated. - // - POST /primaryResource (HasOne, HasMany and HasManyThrough) - // - PATCH /primary resource (HasOne, HasMany and HasManyThrough) - // example: - // var requestBody = new - // { - // data = new - // { - // type = "workItems", - // id = 1, - // attributes = new - // { - // }, - // relationships = new - // { - // assignee = new - // { - // type = "rgbColors", // mismatch: expected userAccount (because of assignee) - // id = 2 - // } - // } - // } - // }; - public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> { From c0a52aa25ad7e28202a0cf65a2a9eb8bb2357c96 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 3 Nov 2020 10:47:05 +0100 Subject: [PATCH 216/240] fix: fix bug for update/create with include/fieldset --- .../Repositories/EntityFrameworkCoreRepository.cs | 9 +++++---- .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 4 ++-- .../Updating/Resources/UpdateToOneRelationshipTests.cs | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7b7dd2942a..c24dc8c463 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -221,7 +221,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r } await SaveChangesAsync(); - + FlushFromCache(resourceFromDatabase); } @@ -399,8 +399,9 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi private void FlushFromCache(IIdentifiable resource) { - var trackedResource = _dbContext.GetTrackedIdentifiable(resource); - Detach(trackedResource); + resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); + Detach(resource); + DetachRelationships(resource); } private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, TId primaryResourceId, ISet secondaryResourceIds) @@ -656,7 +657,7 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private void DetachRelationships(TResource resource) + private void DetachRelationships(IIdentifiable resource) { foreach (var relationship in _targetedFields.Relationships) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index 87d601caa4..1dd6468ac6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -272,7 +272,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Can_replace_HasMany_relationship_with_include() { // Arrange @@ -339,7 +339,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Can_replace_HasManyThrough_relationship_with_include_and_fieldsets() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 62e590f776..028094e846 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -250,7 +250,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Can_create_relationship_with_include() { // Arrange From 6b070e59013e5ca5763b5dac06afba44e67fbfe9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 11:49:39 +0100 Subject: [PATCH 217/240] Remove explicit foreign key properties from models --- .../Data/AppDbContext.cs | 21 ++-- .../Models/NonJsonApiResource.cs | 7 -- .../JsonApiDotNetCoreExample/Models/Person.cs | 3 - .../Models/TodoItem.cs | 19 +-- .../Models/TodoItemCollection.cs | 2 - .../Spec/UpdatingRelationshipsTests.cs | 4 +- .../Acceptance/TodoItemControllerTests.cs | 58 ++++----- .../Create/AfterCreateTests.cs | 10 +- .../Create/BeforeCreate_WithDbValues_Tests.cs | 95 +++++++------- .../Update/BeforeUpdate_WithDbValues_Tests.cs | 118 +++++++++--------- 10 files changed, 149 insertions(+), 188 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 1b9b965c2c..c951e412e6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -39,23 +39,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(t => t.Assignee) - .WithMany(p => p.AssignedTodoItems) - .HasForeignKey(t => t.AssigneeId); + .WithMany(p => p.AssignedTodoItems); modelBuilder.Entity() .HasOne(t => t.Owner) .WithMany(p => p.TodoItems); modelBuilder.Entity() - .HasKey(bc => new { bc.ArticleId, bc.TagId }); + .HasKey(bc => new {bc.ArticleId, bc.TagId}); modelBuilder.Entity() - .HasKey(bc => new { bc.ArticleId, bc.TagId }); + .HasKey(bc => new {bc.ArticleId, bc.TagId}); modelBuilder.Entity() .HasOne(t => t.StakeHolderTodoItem) .WithMany(t => t.StakeHolders) - .HasForeignKey(t => t.StakeHolderTodoItemId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() @@ -63,13 +61,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasMany(t => t.ChildrenTodos) - .WithOne(t => t.ParentTodo) - .HasForeignKey(t => t.ParentTodoId); + .WithOne(t => t.ParentTodo); modelBuilder.Entity() .HasOne(p => p.Person) .WithOne(p => p.Passport) - .HasForeignKey(p => p.PassportId) + .HasForeignKey("PassportKey") .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() @@ -80,7 +77,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(p => p.OneToOnePerson) .WithOne(p => p.OneToOneTodoItem) - .HasForeignKey(p => p.OneToOnePersonId); + .HasForeignKey("OneToOnePersonKey"); modelBuilder.Entity() .HasOne(p => p.Owner) @@ -88,9 +85,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() - .HasOne(p => p.OneToOneTodoItem) - .WithOne(p => p.OneToOnePerson) - .HasForeignKey(p => p.OneToOnePersonId); + .HasOne(p => p.Role) + .WithOne(p => p.Person) + .HasForeignKey("PersonRoleKey"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs deleted file mode 100644 index 7f979f4cfb..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace JsonApiDotNetCoreExample.Models -{ - public class NonJsonApiResource - { - public int Id { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index db610ac0f2..b5d67fb5a0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -57,20 +57,17 @@ public string FirstName [HasOne] public PersonRole Role { get; set; } - public int? PersonRoleId { get; set; } [HasOne] public TodoItem OneToOneTodoItem { get; set; } [HasOne] public TodoItem StakeHolderTodoItem { get; set; } - public int? StakeHolderTodoItemId { get; set; } [HasOne(Links = LinkTypes.All, CanInclude = false)] public TodoItem UnIncludeableItem { get; set; } [HasOne] public Passport Passport { get; set; } - public int? PassportId { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 7e50b2ced0..921e8bcc4c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,11 +7,6 @@ namespace JsonApiDotNetCoreExample.Models { public class TodoItem : Identifiable, IIsLockable { - public TodoItem() - { - GuidProperty = Guid.NewGuid(); - } - public bool IsLocked { get; set; } [Attr] @@ -21,7 +16,7 @@ public TodoItem() public long Ordinal { get; set; } [Attr] - public Guid GuidProperty { get; set; } + public Guid GuidProperty { get; set; } = Guid.NewGuid(); [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] public string AlwaysChangingValue @@ -45,12 +40,6 @@ public string AlwaysChangingValue [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public DateTimeOffset? OffsetDate { get; set; } - //public int? OwnerId { get; set; } - - public int? AssigneeId { get; set; } - - public Guid? CollectionId { get; set; } - [HasOne] public Person Owner { get; set; } @@ -60,8 +49,6 @@ public string AlwaysChangingValue [HasOne] public Person OneToOnePerson { get; set; } - public int? OneToOnePersonId { get; set; } - [HasMany] public ISet StakeHolders { get; set; } @@ -69,14 +56,10 @@ public string AlwaysChangingValue public TodoItemCollection Collection { get; set; } // cyclical to-one structure - public int? DependentOnTodoId { get; set; } - [HasOne] public TodoItem DependentOnTodo { get; set; } // cyclical to-many structure - public int? ParentTodoId {get; set;} - [HasOne] public TodoItem ParentTodo { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index 9b0a515f25..4f3c88e152 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -16,7 +16,5 @@ public sealed class TodoItemCollection : Identifiable [HasOne] public Person Owner { get; set; } - - public int? OwnerId { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 4b42fbc8f1..34dbf2c814 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -140,7 +140,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(item => item.DependentOnTodo) .FirstAsync(item => item.Id == todoItem.Id); - todoItemInDatabase.DependentOnTodoId.Should().Be(todoItem.Id); + todoItemInDatabase.DependentOnTodo.Id.Should().Be(todoItem.Id); }); } @@ -207,7 +207,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(item => item.ParentTodo) .FirstAsync(item => item.Id == todoItem.Id); - todoItemInDatabase.ParentTodoId.Should().Be(todoItem.Id); + todoItemInDatabase.ParentTodo.Id.Should().Be(todoItem.Id); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index 637c41e77d..b6bbaf3d0a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -31,6 +31,7 @@ public TodoItemControllerTests(TestFixture fixture) { _fixture = fixture; _context = fixture.GetRequiredService(); + _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()) @@ -46,20 +47,20 @@ public TodoItemControllerTests(TestFixture fixture) public async Task Can_Get_TodoItems_Paginate_Check() { // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); var expectedResourcesPerPage = _fixture.GetRequiredService().DefaultPageSize.Value; - var person = new Person(); + + var person = _personFaker.Generate(); var todoItems = _todoItemFaker.Generate(expectedResourcesPerPage + 1); foreach (var todoItem in todoItems) { todoItem.Owner = person; _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - } + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); + var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; var request = new HttpRequestMessage(httpMethod, route); @@ -79,9 +80,9 @@ public async Task Can_Get_TodoItems_Paginate_Check() public async Task Can_Get_TodoItem_ById() { // Arrange - var person = new Person(); var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -107,14 +108,10 @@ public async Task Can_Get_TodoItem_ById() public async Task Can_Post_TodoItem() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var serializer = _fixture.GetSerializer(e => new { e.Description, e.OffsetDate, e.Ordinal, e.CreatedDate }, e => new { e.Owner }); + var nowOffset = new DateTimeOffset(); var todoItem = _todoItemFaker.Generate(); - var nowOffset = new DateTimeOffset(); todoItem.OffsetDate = nowOffset; var httpMethod = new HttpMethod("POST"); @@ -144,13 +141,14 @@ public async Task Can_Post_TodoItem() public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() { // Arrange - var person1 = new Person(); - var person2 = new Person(); - _context.People.Add(person1); - _context.People.Add(person2); + var person1 = _personFaker.Generate(); + var person2 = _personFaker.Generate(); + + _context.People.AddRange(person1, person2); await _context.SaveChangesAsync(); var todoItem = _todoItemFaker.Generate(); + var content = new { data = new @@ -203,22 +201,22 @@ public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() var resultId = int.Parse(document.SingleData.Id); // Assert -- database - var todoItemResult = await _context.TodoItems.SingleAsync(t => t.Id == resultId); + var todoItemResult = await _context.TodoItems + .Include(t => t.Owner) + .Include(t => t.Assignee) + .SingleAsync(t => t.Id == resultId); Assert.Equal(person1.Id, todoItemResult.Owner.Id); - Assert.Equal(person2.Id, todoItemResult.AssigneeId); + Assert.Equal(person2.Id, todoItemResult.Assignee.Id); } [Fact] public async Task Can_Patch_TodoItem() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -266,13 +264,10 @@ public async Task Can_Patch_TodoItem() public async Task Can_Patch_TodoItemWithNullable() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var todoItem = _todoItemFaker.Generate(); todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -321,13 +316,10 @@ public async Task Can_Patch_TodoItemWithNullable() public async Task Can_Patch_TodoItemWithNullValue() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var todoItem = _todoItemFaker.Generate(); todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs index a465f7dce6..30d391a394 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs @@ -8,14 +8,14 @@ namespace UnitTests.ResourceHooks { public sealed class AfterCreateTests : HooksTestsSetup { - private readonly ResourceHook[] targetHooks = { ResourceHook.AfterCreate, ResourceHook.AfterUpdateRelationship }; + private readonly ResourceHook[] _targetHooks = { ResourceHook.AfterCreate, ResourceHook.AfterUpdateRelationship }; [Fact] public void AfterCreate() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var todoList = CreateTodoWithOwner(); @@ -33,7 +33,7 @@ public void AfterCreate_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var todoList = CreateTodoWithOwner(); @@ -49,7 +49,7 @@ public void AfterCreate_Without_Parent_Hook_Implemented() public void AfterCreate_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var todoList = CreateTodoWithOwner(); diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs index 2efe5e1391..b8ad10bd11 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs @@ -11,56 +11,59 @@ namespace UnitTests.ResourceHooks { public sealed class BeforeCreate_WithDbValues_Tests : HooksTestsSetup { - private readonly ResourceHook[] targetHooks = { ResourceHook.BeforeCreate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; - private readonly ResourceHook[] targetHooksNoImplicit = { ResourceHook.BeforeCreate, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooks = { ResourceHook.BeforeCreate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooksNoImplicit = { ResourceHook.BeforeCreate, ResourceHook.BeforeUpdateRelationship }; - private readonly string description = "DESCRIPTION"; - private readonly string lastName = "NAME"; - private readonly string personId; - private readonly List todoList; - private readonly DbContextOptions options; + private const string _description = "DESCRIPTION"; + private const string _lastName = "NAME"; + private readonly string _personId; + private readonly List _todoList; + private readonly DbContextOptions _options; public BeforeCreate_WithDbValues_Tests() { - todoList = CreateTodoWithToOnePerson(); + _todoList = CreateTodoWithToOnePerson(); - todoList[0].Id = 0; - todoList[0].Description = description; - var _personId = todoList[0].OneToOnePerson.Id; - personId = _personId.ToString(); + _todoList[0].Id = 0; + _todoList[0].Description = _description; + var person = _todoList[0].OneToOnePerson; + person.LastName = _lastName; + _personId = person.Id.ToString(); var implicitTodo = _todoFaker.Generate(); implicitTodo.Id += 1000; - implicitTodo.OneToOnePersonId = _personId; - implicitTodo.Description = description + description; + implicitTodo.OneToOnePerson = person; + implicitTodo.Description = _description + _description; - options = InitInMemoryDb(context => + _options = InitInMemoryDb(context => { - context.Set().Add(new Person { Id = _personId, LastName = lastName }); + context.Set().Add(person); context.Set().Add(implicitTodo); context.SaveChanges(); }); + + _todoList[0].OneToOnePerson = person; } [Fact] public void BeforeCreate() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheckRelationships(rh, description + description)), + It.Is>(rh => TodoCheckRelationships(rh, _description + _description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -71,15 +74,15 @@ public void BeforeCreate_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); @@ -90,17 +93,17 @@ public void BeforeCreate_Without_Parent_Hook_Implemented() public void BeforeCreate_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheckRelationships(rh, description + description)), + It.Is>(rh => TodoCheckRelationships(rh, _description + _description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -110,17 +113,17 @@ public void BeforeCreate_Without_Child_Hook_Implemented() public void BeforeCreate_NoImplicit() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); @@ -132,15 +135,15 @@ public void BeforeCreate_NoImplicit_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); @@ -151,15 +154,15 @@ public void BeforeCreate_NoImplicit_Without_Parent_Hook_Implemented() public void BeforeCreate_NoImplicit_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs index 46000c9318..e87394b743 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs @@ -11,34 +11,32 @@ namespace UnitTests.ResourceHooks { public sealed class BeforeUpdate_WithDbValues_Tests : HooksTestsSetup { - private readonly ResourceHook[] targetHooks = { ResourceHook.BeforeUpdate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; - private readonly ResourceHook[] targetHooksNoImplicit = { ResourceHook.BeforeUpdate, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooks = { ResourceHook.BeforeUpdate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooksNoImplicit = { ResourceHook.BeforeUpdate, ResourceHook.BeforeUpdateRelationship }; - private readonly string description = "DESCRIPTION"; - private readonly string lastName = "NAME"; - private readonly string personId; - private readonly List todoList; - private readonly DbContextOptions options; + private const string _description = "DESCRIPTION"; + private const string _lastName = "NAME"; + private readonly string _personId; + private readonly List _todoList; + private readonly DbContextOptions _options; public BeforeUpdate_WithDbValues_Tests() { - todoList = CreateTodoWithToOnePerson(); + _todoList = CreateTodoWithToOnePerson(); - var todoId = todoList[0].Id; - var _personId = todoList[0].OneToOnePerson.Id; - personId = _personId.ToString(); - var _implicitPersonId = _personId + 10000; + var todoId = _todoList[0].Id; + var personId = _todoList[0].OneToOnePerson.Id; + _personId = personId.ToString(); + var implicitPersonId = personId + 10000; var implicitTodo = _todoFaker.Generate(); implicitTodo.Id += 1000; - implicitTodo.OneToOnePersonId = _personId; - implicitTodo.Description = description + description; + implicitTodo.OneToOnePerson = new Person {Id = personId, LastName = _lastName}; + implicitTodo.Description = _description + _description; - options = InitInMemoryDb(context => + _options = InitInMemoryDb(context => { - context.Set().Add(new Person { Id = _personId, LastName = lastName }); - context.Set().Add(new Person { Id = _implicitPersonId, LastName = lastName + lastName }); - context.Set().Add(new TodoItem { Id = todoId, OneToOnePersonId = _implicitPersonId, Description = description }); + context.Set().Add(new TodoItem {Id = todoId, OneToOnePerson = new Person {Id = implicitPersonId, LastName = _lastName + _lastName}, Description = _description}); context.Set().Add(implicitTodo); context.SaveChanges(); }); @@ -48,26 +46,26 @@ public BeforeUpdate_WithDbValues_Tests() public void BeforeUpdate() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), - It.Is>(rh => PersonCheck(lastName, rh)), + It.Is>(ids => PersonIdCheck(ids, _personId)), + It.Is>(rh => PersonCheck(_lastName, rh)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => PersonCheck(lastName + lastName, rh)), + It.Is>(rh => PersonCheck(_lastName + _lastName, rh)), ResourcePipeline.Patch), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheck(rh, description + description)), + It.Is>(rh => TodoCheck(rh, _description + _description)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -78,20 +76,20 @@ public void BeforeUpdate() public void BeforeUpdate_Deleting_Relationship() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, ufMock, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, ufMock, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); ufMock.Setup(c => c.Relationships).Returns(_resourceGraph.GetRelationships((TodoItem t) => t.OneToOnePerson).ToList()); // Act - var _todoList = new List { new TodoItem { Id = todoList[0].Id } }; - hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); + var todoList = new List { new TodoItem { Id = _todoList[0].Id } }; + hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => PersonCheck(lastName + lastName, rh)), + It.Is>(rh => PersonCheck(_lastName + _lastName, rh)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -103,20 +101,20 @@ public void BeforeUpdate_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), - It.Is>(rh => PersonCheck(lastName, rh)), + It.Is>(ids => PersonIdCheck(ids, _personId)), + It.Is>(rh => PersonCheck(_lastName, rh)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => PersonCheck(lastName + lastName, rh)), + It.Is>(rh => PersonCheck(_lastName + _lastName, rh)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -126,17 +124,17 @@ public void BeforeUpdate_Without_Parent_Hook_Implemented() public void BeforeUpdate_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheck(rh, description + description)), + It.Is>(rh => TodoCheck(rh, _description + _description)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -146,17 +144,17 @@ public void BeforeUpdate_Without_Child_Hook_Implemented() public void BeforeUpdate_NoImplicit() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Patch), Times.Once()); @@ -168,16 +166,16 @@ public void BeforeUpdate_NoImplicit_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), - It.Is>(rh => PersonCheck(lastName, rh)), + It.Is>(ids => PersonIdCheck(ids, _personId)), + It.Is>(rh => PersonCheck(_lastName, rh)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -187,15 +185,15 @@ public void BeforeUpdate_NoImplicit_Without_Parent_Hook_Implemented() public void BeforeUpdate_NoImplicit_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } From c470289cf66072a6b48d9a91c2ae21df898a91fe Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 13:37:54 +0100 Subject: [PATCH 218/240] cleanup example models --- .../Models/ArticleTag.cs | 16 ---------------- .../Models/IdentifiableArticleTag.cs | 18 ++++++++++++++++++ .../JsonApiDotNetCoreExample/Models/Tag.cs | 7 ------- .../Models/TagColor.cs | 9 +++++++++ .../Models/TodoItem.cs | 6 ------ 5 files changed, 27 insertions(+), 29 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs index 317ecf5e65..b0e4d59435 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -1,6 +1,3 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - namespace JsonApiDotNetCoreExample.Models { public sealed class ArticleTag @@ -11,17 +8,4 @@ public sealed class ArticleTag public int TagId { get; set; } public Tag Tag { get; set; } } - - public class IdentifiableArticleTag : Identifiable - { - public int ArticleId { get; set; } - [HasOne] - public Article Article { get; set; } - - public int TagId { get; set; } - [HasOne] - public Tag Tag { get; set; } - - public string SomeMetaData { get; set; } - } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs new file mode 100644 index 0000000000..3183540aba --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public class IdentifiableArticleTag : Identifiable + { + public int ArticleId { get; set; } + [HasOne] + public Article Article { get; set; } + + public int TagId { get; set; } + [HasOne] + public Tag Tag { get; set; } + + public string SomeMetaData { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 296fd559df..749df0a505 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -11,11 +11,4 @@ public class Tag : Identifiable [Attr] public TagColor Color { get; set; } } - - public enum TagColor - { - Red, - Green, - Blue - } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs new file mode 100644 index 0000000000..8ae4552afe --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExample.Models +{ + public enum TagColor + { + Red, + Green, + Blue + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 921e8bcc4c..64afada036 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -15,9 +15,6 @@ public class TodoItem : Identifiable, IIsLockable [Attr] public long Ordinal { get; set; } - [Attr] - public Guid GuidProperty { get; set; } = Guid.NewGuid(); - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] public string AlwaysChangingValue { @@ -31,9 +28,6 @@ public string AlwaysChangingValue [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? AchievedDate { get; set; } - [Attr] - public DateTime? UpdatedDate { get; set; } - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public string CalculatedValue => "calculated"; From f17a90f8c66a6532238dd5e9f28a763772e0b23b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 13:39:53 +0100 Subject: [PATCH 219/240] removed left-over todo --- .../Resources/Annotations/HasManyThroughAttribute.cs | 7 ++----- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 6f523ba14b..4747abe26d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -91,19 +91,16 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; - // TODO: Setting these doesn't even work. Use them on ArticleTag and lots of tests start to fail. - // Either make it work or remove the feature. - /// /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. - /// Defaults to the name of suffixed with "Id". + /// Defaults to the name of suffixed with "Id". /// In the example described above, this would be "ArticleId". /// public string LeftIdPropertyName { get; set; } /// /// Optional. Can be used to indicate a non-default name for the ID property to the related resource from the through type. - /// Defaults to the name of suffixed with "Id". + /// Defaults to the name of suffixed with "Id". /// In the example described above, this would be "TagId". /// public string RightIdPropertyName { get; set; } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index b661708cfe..6d05cf761b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -562,7 +562,7 @@ private void AssertHasManyRelationshipValueIsNotNull(object secondaryResourceIds { if (secondaryResourceIds == null) { - // TODO: Usage of InvalidRequestBodyException (here and in BaseJsonApiController) is probably not the nest choice, because they do not contain request body. + // TODO: Usage of InvalidRequestBodyException (here and in BaseJsonApiController) is probably not the best choice, because they do not contain request body. // We should either make it include the request body -or- throw a different exception. throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", null, null); } From 52a0421b0913ab67d4973857dfd043cdf09e7ca1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 13:54:40 +0100 Subject: [PATCH 220/240] Reduced usage of InvalidRequestBodyException --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 7 ++----- test/UnitTests/Controllers/BaseJsonApiController_Tests.cs | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 6d3ae7d806..616dce670e 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -155,13 +155,11 @@ public virtual async Task GetRelationshipAsync(TId id, string rel public virtual async Task PostAsync([FromBody] TResource resource) { _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); if (_create == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - if (resource == null) - throw new InvalidRequestBodyException(null, null, null); - if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) throw new ResourceIdInPostRequestNotAllowedException(); @@ -205,10 +203,9 @@ public virtual async Task PostRelationshipAsync(TId id, string re public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) { _traceWriter.LogMethodStart(new {id, resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - if (resource == null) - throw new InvalidRequestBodyException(null, null, null); if (_options.ValidateModelState && !ModelState.IsValid) { diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 488f263f07..57e41f7e96 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -191,10 +191,11 @@ public async Task PatchAsync_Throws_405_If_No_Service() { // Arrange const int id = 0; + var resource = new Resource(); var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); + var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, resource)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); From c9bcba81b3766b79ddc707b3df7ce9378282a1b7 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 3 Nov 2020 14:30:25 +0100 Subject: [PATCH 221/240] feat: preview spec compliance in 404 remove from relationship endpoint --- .../Repositories/DataStoreUpdateException.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 200 +++++++++++------- .../Services/JsonApiResourceService.cs | 2 + .../RemoveFromToManyRelationshipTests.cs | 5 +- .../ReplaceToManyRelationshipTests.cs | 13 +- 5 files changed, 128 insertions(+), 94 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index d5e05557a2..b3463f5ae4 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Repositories /// public sealed class DataStoreUpdateException : Exception { - public DataStoreUpdateException(Exception exception) + public DataStoreUpdateException(Exception exception = null) : base("Failed to persist changes in the underlying data store.", exception) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index c24dc8c463..4dd4d1e297 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -188,9 +188,10 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); - var relationship = _targetedFields.Relationships.Single(); - TResource primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); + var primaryResource = await TryGetPrimaryResource(id); + var relationship = _targetedFields.Relationships.Single(); + await EnableCompleteReplacement(relationship, primaryResource); await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); @@ -283,14 +284,15 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); + var rightResources = ((IEnumerable)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); await ApplyRelationshipUpdate(relationship, primaryResource, rightResources); @@ -380,7 +382,7 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi { if (relationship is HasManyThroughAttribute hasManyThroughRelationship) { - var throughEntities = await GetFilteredThroughEntities_StaticQueryBuilding(hasManyThroughRelationship, resource.Id, null); + var throughEntities = await GetFilteredRightEntities_StaticQueryBuilding(resource.Id, hasManyThroughRelationship.LeftIdProperty, null, null); hasManyThroughRelationship.ThroughProperty.SetValue(resource, TypeHelper.CopyToTypedCollection(throughEntities, hasManyThroughRelationship.ThroughProperty.PropertyType)); foreach (var throughEntity in throughEntities) @@ -397,7 +399,7 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi } } - private void FlushFromCache(IIdentifiable resource) + private void FlushFromCache(IIdentifiable resource) { resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); Detach(resource); @@ -409,7 +411,7 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt object[] throughEntities; // TODO: Finalize this. - throughEntities = await GetFilteredThroughEntities_StaticQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + throughEntities = await GetFilteredRightEntities_StaticQueryBuilding(primaryResourceId, hasManyThroughRelationship.LeftIdProperty, secondaryResourceIds,hasManyThroughRelationship.RightIdProperty, hasManyThroughRelationship.ThroughType); // Alternative approaches: // throughEntities = await GetFilteredThroughEntities_DynamicQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); @@ -421,111 +423,123 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt Detach(throughEntities); } - private async Task GetFilteredThroughEntities_StaticQueryBuilding(HasManyThroughAttribute hasManyThroughRelationship, TId leftIdFilter, ISet rightIdFilter) + private async Task GetFilteredRightEntities_StaticQueryBuilding(object idToEqual, PropertyInfo equaledIdProperty, ISet idsToContain, PropertyInfo containedIdProperty) { - dynamic dummyInstance = Activator.CreateInstance(hasManyThroughRelationship.ThroughType); - return await ((dynamic)this).GetFilteredThroughEntities_StaticQueryBuilding(dummyInstance, hasManyThroughRelationship, leftIdFilter, rightIdFilter); + var rightType = equaledIdProperty?.ReflectedType ?? idsToContain.First().GetType(); + dynamic runtimeTypeParameter = _resourceFactory.CreateInstance(rightType); + + return await ((dynamic)this).GetFilteredRightEntities_StaticQueryBuilding(idToEqual, equaledIdProperty, idsToContain, containedIdProperty, runtimeTypeParameter); } - public async Task GetFilteredThroughEntities_StaticQueryBuilding(TThroughType _, HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) where TThroughType : class + private async Task GetFilteredRightEntities_StaticQueryBuilding(object idToEqual, PropertyInfo equaledIdProperty, ISet idsToContain, PropertyInfo containedIdProperty, TRightType _) where TRightType : class { - var filter = GetThroughEntityFilterExpression(relationship, leftIdFilter, rightIdFilter); + var filter = GetThroughEntityFilterExpression(idToEqual, equaledIdProperty, idsToContain, containedIdProperty); - var result = await _dbContext.Set().Where(filter).ToListAsync(); + var result = await _dbContext.Set().Where(filter).ToListAsync(); return result.Cast().ToArray(); } - private Expression> GetThroughEntityFilterExpression(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) where TThroughType : class + private Expression> GetThroughEntityFilterExpression(object idToEqual, PropertyInfo equaledIdProperty, ISet idsToContain, PropertyInfo containedIdProperty) where TRightType : class { - var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); + var rightType = typeof(TRightType); + var rightEntityParameter = Expression.Parameter(rightType, rightType.Name.Camelize()); - Expression filter = GetEqualsCall(relationship, throughEntityParameter, leftIdFilter); + Expression filter = null; - if (rightIdFilter != null) + if (idToEqual != null) { - var containsCall = GetContainsCall(relationship, throughEntityParameter, rightIdFilter); - filter = Expression.AndAlso(filter, containsCall); - } - - return Expression.Lambda>(filter, throughEntityParameter); - } - - private async Task GetFilteredThroughEntities_DynamicQueryBuilding(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) - { - var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); - - var containsCall = GetContainsCall(relationship, throughEntityParameter, secondaryResourceIds) ; - var equalsCall = GetEqualsCall(relationship, throughEntityParameter, primaryResourceId); - var conjunction = Expression.AndAlso(equalsCall, containsCall); + filter = GetEqualsCall(idToEqual, rightEntityParameter, equaledIdProperty); + } - var predicate = Expression.Lambda(conjunction, throughEntityParameter); - - IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); - - dynamic query = throughSource.Provider.CreateQuery(whereClause); - IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); - - return result.Cast().ToArray(); - } - - private async Task GetFilteredThroughEntities_QueryBuilderCall(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) - { - var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); - var comparisionId = new LiteralConstantExpression(leftIdFilter.ToString()); - FilterExpression filter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); - - if (rightIdFilter != null) + if (idsToContain != null) { - var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); - var equalsAnyOfIds = rightIdFilter.Select(r => new LiteralConstantExpression(r.StringId)).ToArray(); - var equalsAnyOf = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); - filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { filter, equalsAnyOf } ); + var containsFilter = GetContainsCall(idsToContain, rightEntityParameter, containedIdProperty); + filter = filter == null ? (Expression) containsFilter : Expression.AndAlso(filter, containsFilter); } - - IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - - var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); - var scope = scopeFactory.CreateScope(relationship.ThroughType); - - var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); - var whereClause = whereClauseBuilder.ApplyWhere(filter); - dynamic query = throughSource.Provider.CreateQuery(whereClause); - IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); - - return result.Cast().ToArray(); - } + return filter == null ? null : Expression.Lambda>(filter, rightEntityParameter); + } + + // private async Task GetFilteredThroughEntities_DynamicQueryBuilding(TId primaryResourceId, ISet secondaryResourceIds) + // { + // var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); + // + // var containsCall = GetContainsCall(relationship, throughEntityParameter, secondaryResourceIds) ; + // var equalsCall = GetEqualsCall(relationship, throughEntityParameter, primaryResourceId); + // var conjunction = Expression.AndAlso(equalsCall, containsCall); + // + // var predicate = Expression.Lambda(conjunction, throughEntityParameter); + // + // IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + // var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); + // + // dynamic query = throughSource.Provider.CreateQuery(whereClause); + // IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + // + // return result.Cast().ToArray(); + // } + + // private async Task GetFilteredThroughEntities_QueryBuilderCall(HasManyThroughAttribute relationship, TId leftIdFilter, ISet rightIdFilter) + // { + // var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); + // var comparisionId = new LiteralConstantExpression(leftIdFilter.ToString()); + // FilterExpression filter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); + // + // if (rightIdFilter != null) + // { + // var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); + // var equalsAnyOfIds = rightIdFilter.Select(r => new LiteralConstantExpression(r.StringId)).ToArray(); + // var equalsAnyOf = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); + // filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { filter, equalsAnyOf } ); + // } + // + // IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + // + // var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); + // var scope = scopeFactory.CreateScope(relationship.ThroughType); + // + // var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); + // var whereClause = whereClauseBuilder.ApplyWhere(filter); + // + // dynamic query = throughSource.Provider.CreateQuery(whereClause); + // IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + // + // return result.Cast().ToArray(); + // } private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity, HasManyThroughAttribute relationship) { var rightResource = _resourceFactory.CreateInstance(relationship.RightType); rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); - + return rightResource; } - - private MethodCallExpression GetContainsCall(HasManyThroughAttribute relationship, ParameterExpression throughEntityParameter, ISet secondaryResourceIds) + + private MethodCallExpression GetContainsCall(ISet secondaryResourceIds, ParameterExpression rightEntityParameter, PropertyInfo rightIdProperty) { - var rightIdProperty = Expression.Property(throughEntityParameter, relationship.RightIdProperty.Name); - - var idType = relationship.RightIdProperty.PropertyType; + var rightIdMember = Expression.Property(rightEntityParameter, rightIdProperty.Name); + + var idType = rightIdProperty.PropertyType; var typedIds = TypeHelper.CopyToList(secondaryResourceIds.Select(r => r.GetTypedId()), idType); var idCollectionConstant = Expression.Constant(typedIds); - - var containsCall = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] {idType}, - idCollectionConstant, rightIdProperty); + + var containsCall = Expression.Call( + typeof(Enumerable), + nameof(Enumerable.Contains), + new[] {idType}, + idCollectionConstant, + rightIdMember); return containsCall; } - private BinaryExpression GetEqualsCall(HasManyThroughAttribute relationship, ParameterExpression throughEntityParameter, TId primaryResourceId) + private BinaryExpression GetEqualsCall(object id, ParameterExpression rightEntityParameter, PropertyInfo leftIdProperty) { - var leftIdProperty = Expression.Property(throughEntityParameter, relationship.LeftIdProperty.Name); - var idConstant = Expression.Constant(primaryResourceId, typeof(TId)); - - return Expression.Equal(leftIdProperty, idConstant); + var leftIdMember = Expression.Property(rightEntityParameter, leftIdProperty.Name); + var idConstant = Expression.Constant(id, id.GetType()); + + return Expression.Equal(leftIdMember, idConstant); } private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) @@ -657,6 +671,30 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } + private async Task TryGetPrimaryResource(TId id) + { + var primaryResource = await _dbContext.FindAsync(id); + if (primaryResource == null) + { + throw new DataStoreUpdateException(); + } + + return primaryResource; + } + + private async Task AssertSecondaryResourcesExist(ISet secondaryResourceIds, HasManyAttribute relationship) + { + var rightPrimaryKey = relationship.RightType.GetProperty(nameof(Identifiable.Id)); + var secondaryResourcesFromDatabase = + (await GetFilteredRightEntities_StaticQueryBuilding(null, null, secondaryResourceIds, rightPrimaryKey)) + .Cast().ToArray(); + + if (secondaryResourcesFromDatabase.Length < secondaryResourceIds.Count) + { + throw new DataStoreUpdateException(); + } + } + private void DetachRelationships(IIdentifiable resource) { foreach (var relationship in _targetedFields.Relationships) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index b661708cfe..4566dc020e 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -384,6 +384,8 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN catch (DataStoreUpdateException) { await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + + await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 3f4f2a2193..9b8f483806 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -362,7 +362,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(2); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + // TODO: Make error message more general so that it can be reused for this case. + // Proposed: A referenced secondary resource does not exist. responseDocument.Errors[0].Title.Should().Be("A resource being removed from a relationship does not exist."); + // Proposed: "Resource of type 'userAccounts' with ID '88888888' referenced through the relationship 'subscribers' does not exist." responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being removed from relationship 'subscribers' does not exist."); responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -370,7 +373,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[1].Detail.Should().Be("Resource of type 'userAccounts' with ID '99999999' being removed from relationship 'subscribers' does not exist."); } - [Fact(Skip = "TODO: Fix bug that prevents this test from succeeding.")] + [Fact] public async Task Cannot_remove_unknown_IDs_from_HasManyThrough_relationship() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 9e6754bf1a..de6bd67523 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -501,14 +501,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } - - // TODO: replacing primary data with new object[0] should still fail, but it doesn't. [Fact] public async Task Cannot_replace_on_unknown_resource_ID_in_url() { // Arrange var existingSubscriber = _fakers.UserAccount.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.UserAccounts.Add(existingSubscriber); @@ -517,14 +515,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = new[] - { - new - { - type = "userAccounts", - id = existingSubscriber.StringId - } - } + data = new object[0] }; var route = "/workItems/99999999/relationships/subscribers"; From 0e0f693d99e38f0c45af2a4f71a1ae61d66304b7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 14:31:56 +0100 Subject: [PATCH 222/240] Moved throwing InvalidRequestBodyException out of resource service. --- .../Serialization/BaseDeserializer.cs | 18 +++++++------ .../Serialization/JsonApiReader.cs | 13 +++++++++ .../Services/JsonApiResourceService.cs | 27 ------------------- ...eateResourceWithToManyRelationshipTests.cs | 4 +-- .../ReplaceToManyRelationshipTests.cs | 4 +-- .../ReplaceToManyRelationshipTests.cs | 4 +-- .../Common/DocumentParserTests.cs | 4 +-- 7 files changed, 31 insertions(+), 43 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index a8d9a40d4e..bb4e45f957 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -234,17 +234,19 @@ private void SetHasManyRelationship( HasManyAttribute hasManyRelationship, RelationshipEntry relationshipData) { - // If the relationship data is null, there is no need to set the navigation property to null: this is the default value. - if (relationshipData.ManyData != null) + if (relationshipData.ManyData == null) { - var rightResources = relationshipData.ManyData - .Select(rio => CreateRightResourceForHasMany(hasManyRelationship, rio)) - .ToHashSet(IdentifiableComparer.Instance); - - var convertedCollection = TypeHelper.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); - hasManyRelationship.SetValue(resource, convertedCollection); + throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", + $"Expected data[] for '{hasManyRelationship.PublicName}' relationship.", null); } + var rightResources = relationshipData.ManyData + .Select(rio => CreateRightResourceForHasMany(hasManyRelationship, rio)) + .ToHashSet(IdentifiableComparer.Instance); + + var convertedCollection = TypeHelper.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); + hasManyRelationship.SetValue(resource, convertedCollection); + AfterProcessField(resource, hasManyRelationship, relationshipData); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 5e740edc93..e273c14801 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; @@ -108,6 +109,12 @@ private void ValidateRequestBody(object model, string body, HttpRequest httpRequ ValidateRequestIncludesId(model, body); ValidatePrimaryIdValue(model, httpRequest.Path); } + + if (IsPatchRequestForToManyRelationship(httpRequest.Method) && model == null) + { + throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", + $"Expected data[] for '{_request.Relationship.PublicName}' relationship.", body); + } } private void ValidateIncomingResourceType(object model, HttpRequest httpRequest) @@ -204,5 +211,11 @@ private static bool TryGetId(object model, out string id) id = null; return false; } + + private bool IsPatchRequestForToManyRelationship(string requestMethod) + { + return requestMethod == HttpMethods.Patch && _request.Kind == EndpointKind.Relationship && + _request.Relationship is HasManyAttribute; + } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6d05cf761b..eae38c5246 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -182,12 +182,6 @@ public virtual async Task CreateAsync(TResource resource) _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - foreach (var hasManyRelationship in _targetedFields.Relationships.OfType()) - { - var rightResources = hasManyRelationship.GetValue(resource); - AssertHasManyRelationshipValueIsNotNull(rightResources); - } - var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); @@ -262,12 +256,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _traceWriter.LogMethodStart(new {id, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - foreach (var hasManyRelationship in _targetedFields.Relationships.OfType()) - { - var rightResources = hasManyRelationship.GetValue(resource); - AssertHasManyRelationshipValueIsNotNull(rightResources); - } - AssertResourceIdIsNotTargeted(); var resourceFromRequest = resource; @@ -321,11 +309,6 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); - if (_request.Relationship is HasManyAttribute) - { - AssertHasManyRelationshipValueIsNotNull(secondaryResourceIds); - } - await _hookExecutor.BeforeUpdateRelationshipAsync(id, async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); @@ -558,16 +541,6 @@ private void AssertRelationshipIsToMany() } } - private void AssertHasManyRelationshipValueIsNotNull(object secondaryResourceIds) - { - if (secondaryResourceIds == null) - { - // TODO: Usage of InvalidRequestBodyException (here and in BaseJsonApiController) is probably not the best choice, because they do not contain request body. - // We should either make it include the request body -or- throw a different exception. - throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", null, null); - } - } - private enum TopFieldSelection { /// diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs index 45d8785e8d..b5278a1847 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -625,7 +625,7 @@ public async Task Cannot_create_with_null_data_in_HasMany_relationship() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); } [Fact] @@ -658,7 +658,7 @@ public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 9e6754bf1a..6886ebe54e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -699,7 +699,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); } [Fact] @@ -730,7 +730,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index 1dd6468ac6..b32c82df87 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -790,7 +790,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); } [Fact] @@ -832,7 +832,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); } } } diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 8d8e610ad8..fcaa24a14e 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -347,7 +347,7 @@ public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationIsPop public void DeserializeRelationships_EmptyOneToManyDependent_NavigationIsNull() { // Arrange - var content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents"); + var content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents", isToManyData: true); var body = JsonConvert.SerializeObject(content); // Act @@ -355,7 +355,7 @@ public void DeserializeRelationships_EmptyOneToManyDependent_NavigationIsNull() // Assert Assert.Equal(1, result.Id); - Assert.Null(result.Dependents); + Assert.Empty(result.Dependents); Assert.Null(result.AttributeMember); } From 12e082f355148fd9bfdc27f283351d931bfb9a2a Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 3 Nov 2020 14:34:15 +0100 Subject: [PATCH 223/240] fix: cleanup FlushFromCache --- .../EntityFrameworkCoreRepository.cs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 4dd4d1e297..e85d6ae70e 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -154,10 +154,6 @@ public virtual async Task CreateAsync(TResource resource) await SaveChangesAsync(); FlushFromCache(resource); - - // This ensures relationships get reloaded from the database if they have - // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343. - DetachRelationships(resource); } /// @@ -402,8 +398,11 @@ protected async Task EnableCompleteReplacement(RelationshipAttribute relationshi private void FlushFromCache(IIdentifiable resource) { resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); - Detach(resource); - DetachRelationships(resource); + if (resource != null) + { + DetachEntities(resource); + DetachRelationships(resource); + } } private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute hasManyThroughRelationship, TId primaryResourceId, ISet secondaryResourceIds) @@ -420,7 +419,7 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt var rightResources = throughEntities.Select(entity => ConstructRightResourceOfHasManyRelationship(entity, hasManyThroughRelationship)).ToHashSet(); secondaryResourceIds.ExceptWith(rightResources); - Detach(throughEntities); + DetachEntities(throughEntities); } private async Task GetFilteredRightEntities_StaticQueryBuilding(object idToEqual, PropertyInfo equaledIdProperty, ISet idsToContain, PropertyInfo containedIdProperty) @@ -599,7 +598,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio relationship.SetValue(leftResource, placeholderRightResource); _dbContext.Entry(leftResource).DetectChanges(); - Detach(placeholderRightResource); + DetachEntities(placeholderRightResource); } private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) @@ -703,17 +702,17 @@ private void DetachRelationships(IIdentifiable resource) if (rightValue is IEnumerable rightResources) { - Detach(rightResources.ToArray()); + DetachEntities(rightResources.ToArray()); } else if (rightValue != null) { - Detach(rightValue); + DetachEntities(rightValue); _dbContext.Entry(rightValue).State = EntityState.Detached; } } } - private void Detach(params object[] entities) + private void DetachEntities(params object[] entities) { foreach (var entity in entities) { From f0cd7f0b76e9d551e6066dbfcbf04317887f629d Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 3 Nov 2020 14:45:26 +0100 Subject: [PATCH 224/240] fix: resource factory usage bug in static query building --- .../Repositories/EntityFrameworkCoreRepository.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index e85d6ae70e..0773083105 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -424,8 +424,11 @@ private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAtt private async Task GetFilteredRightEntities_StaticQueryBuilding(object idToEqual, PropertyInfo equaledIdProperty, ISet idsToContain, PropertyInfo containedIdProperty) { - var rightType = equaledIdProperty?.ReflectedType ?? idsToContain.First().GetType(); - dynamic runtimeTypeParameter = _resourceFactory.CreateInstance(rightType); + var rightType = equaledIdProperty?.ReflectedType ?? idsToContain.First().GetType(); + + dynamic runtimeTypeParameter = TypeHelper.IsOrImplementsInterface(rightType, typeof(IIdentifiable)) + ? _resourceFactory.CreateInstance(rightType) + : TypeHelper.CreateInstance(rightType); return await ((dynamic)this).GetFilteredRightEntities_StaticQueryBuilding(idToEqual, equaledIdProperty, idsToContain, containedIdProperty, runtimeTypeParameter); } From bfc677353d383942cebdb2b558dc3680c64d52a2 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 14:45:43 +0100 Subject: [PATCH 225/240] Wrap serialization errors --- .../Errors/InvalidRequestBodyException.cs | 8 +------ .../Hooks/Internal/Traversal/IResourceNode.cs | 1 - .../Resources/Annotations/HasManyAttribute.cs | 1 - .../Serialization/BaseDeserializer.cs | 23 ++++++++----------- .../Serialization/JsonApiReader.cs | 5 ++-- .../JsonApiSerializationException.cs | 20 ++++++++++++++++ .../Serialization/RequestDeserializer.cs | 9 ++++---- .../ModelStateValidationTests.cs | 1 - ...reateResourceWithClientGeneratedIdTests.cs | 1 - ...eateResourceWithToManyRelationshipTests.cs | 1 - .../Writing/Deleting/DeleteResourceTests.cs | 1 - .../ReplaceToManyRelationshipTests.cs | 1 - .../ResourceHooks/ResourceHooksTestsSetup.cs | 1 - .../Common/ResourceObjectBuilderTests.cs | 1 - 14 files changed, 37 insertions(+), 37 deletions(-) create mode 100644 src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index fb607fc56d..e49a453e07 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Errors public sealed class InvalidRequestBodyException : JsonApiException { private readonly string _details; - private string _requestBody; + private readonly string _requestBody; public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) : base(new Error(HttpStatusCode.UnprocessableEntity) @@ -42,11 +42,5 @@ private void UpdateErrorDetail() Error.Detail = text; } - - public void SetRequestBody(string requestBody) - { - _requestBody = requestBody; - UpdateErrorDetail(); - } } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs index 7f687598fc..de364e8ccc 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs @@ -1,5 +1,4 @@ using System.Collections; -using JsonApiDotNetCore.Resources; using RightType = System.Type; namespace JsonApiDotNetCore.Hooks.Internal.Traversal diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 8aa4975432..1555a24f7f 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; namespace JsonApiDotNetCore.Resources.Annotations { diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index bb4e45f957..2d06b586c3 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Client.Internal; @@ -85,8 +84,7 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary ReadAsync(InputFormatterContext context) { model = _deserializer.Deserialize(body); } - catch (InvalidRequestBodyException exception) + catch (JsonApiSerializationException exception) { - exception.SetRequestBody(body); - throw; + throw new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception); } catch (Exception exception) { diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs new file mode 100644 index 0000000000..482f911c92 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs @@ -0,0 +1,20 @@ +using System; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// The error that is thrown when (de)serialization of a json:api body fails. + /// + public class JsonApiSerializationException : Exception + { + public string GenericMessage { get; } + public string SpecificMessage { get; } + + public JsonApiSerializationException(string genericMessage, string specificMessage, Exception innerException = null) + : base(genericMessage, innerException) + { + GenericMessage = genericMessage; + SpecificMessage = specificMessage; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 07b110e95c..e996cfe969 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,7 +1,6 @@ using System; using System.Net.Http; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -59,17 +58,17 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Post.Method && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { - throw new InvalidRequestBodyException( + throw new JsonApiSerializationException( "Setting the initial value of the requested attribute is not allowed.", - $"Setting the initial value of '{attr.PublicName}' is not allowed.", null); + $"Setting the initial value of '{attr.PublicName}' is not allowed."); } if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { - throw new InvalidRequestBodyException( + throw new JsonApiSerializationException( "Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicName}' is not allowed.", null); + $"Changing the value of '{attr.PublicName}' is not allowed."); } _targetedFields.Attributes.Add(attr); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 412e9dada1..f9b05f28a7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs index 097ba35440..92c19bbdf6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs index b5278a1847..c0301a6cf6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs index 497fa33b8b..3e932dfdfd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -4,7 +4,6 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 6886ebe54e..757789e1ad 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using System.Net; using System.Threading.Tasks; diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 98f6a4b693..9c817e4e41 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; -using IResourceFactory = JsonApiDotNetCore.Resources.IResourceFactory; using Person = JsonApiDotNetCoreExample.Models.Person; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs index 3027921810..a6d06382af 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections; using System.Collections.Generic; using System.Linq; From d110dfaea3cb3fb84337ed25017609ae151ccf81 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 14:51:22 +0100 Subject: [PATCH 226/240] removed unneeded new whitespace --- .../Updating/Relationships/ReplaceToManyRelationshipTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index ab2ad1633d..4f9c35dbc7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -505,7 +505,7 @@ public async Task Cannot_replace_on_unknown_resource_ID_in_url() { // Arrange var existingSubscriber = _fakers.UserAccount.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.UserAccounts.Add(existingSubscriber); From becb87de3d8699407c1a17740e13327ae7245b0b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 3 Nov 2020 17:23:32 +0100 Subject: [PATCH 227/240] comments --- .../Repositories/EntityFrameworkCoreRepository.cs | 13 ++++++------- .../Services/JsonApiResourceService.cs | 1 + .../IntegrationTests/Writing/RgbColor.cs | 1 + .../RemoveFromToManyRelationshipTests.cs | 2 ++ 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 0773083105..777aaaf678 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -184,7 +184,7 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); - var primaryResource = await TryGetPrimaryResource(id); + var primaryResource = await GetPrimaryResourceById(id); var relationship = _targetedFields.Relationships.Single(); @@ -281,7 +281,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet TryGetPrimaryResource(TId id) + private async Task GetPrimaryResourceById(TId id) { var primaryResource = await _dbContext.FindAsync(id); if (primaryResource == null) @@ -686,10 +686,9 @@ private async Task TryGetPrimaryResource(TId id) private async Task AssertSecondaryResourcesExist(ISet secondaryResourceIds, HasManyAttribute relationship) { - var rightPrimaryKey = relationship.RightType.GetProperty(nameof(Identifiable.Id)); + var rightId = relationship.RightType.GetProperty(nameof(Identifiable.Id)); var secondaryResourcesFromDatabase = - (await GetFilteredRightEntities_StaticQueryBuilding(null, null, secondaryResourceIds, rightPrimaryKey)) - .Cast().ToArray(); + await GetFilteredRightEntities_StaticQueryBuilding(null, null, secondaryResourceIds, rightId); if (secondaryResourcesFromDatabase.Length < secondaryResourceIds.Count) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 00a1aac494..544f2d9f8a 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -263,6 +263,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _hookExecutor.BeforeUpdateResource(resourceFromRequest); + // TODO: Call with OnlyAllAttributes (impl: clear all projections => selects all fields, no includes and all eager-loads) TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index 1aeca1faa9..0b260fb0df 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -8,6 +8,7 @@ public sealed class RgbColor : Identifiable [Attr] public string DisplayName { get; set; } + // TODO: Change into required relationship and add a test that fails when trying to assign null. [HasOne] public WorkItemGroup Group { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 9b8f483806..74ca29ac10 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -364,6 +364,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); // TODO: Make error message more general so that it can be reused for this case. // Proposed: A referenced secondary resource does not exist. + // => "A related resource does not exist." + // => "Related resource of type 'workItems' with ID '12345678' in relationship 'assignedItems' does not exist." responseDocument.Errors[0].Title.Should().Be("A resource being removed from a relationship does not exist."); // Proposed: "Resource of type 'userAccounts' with ID '88888888' referenced through the relationship 'subscribers' does not exist." responseDocument.Errors[0].Detail.Should().Be("Resource of type 'userAccounts' with ID '88888888' being removed from relationship 'subscribers' does not exist."); From 067398690d03843e07baba6753a48134081781f9 Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 3 Nov 2020 20:27:38 +0100 Subject: [PATCH 228/240] merge --- .../EntityFrameworkCoreRepository.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 0773083105..e3efe3f6af 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -184,11 +184,11 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI { _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); - var primaryResource = await TryGetPrimaryResource(id); + var primaryResource = await TryGetPrimaryResourceForCompleteReplacement(id); var relationship = _targetedFields.Relationships.Single(); - await EnableCompleteReplacement(relationship, primaryResource); + // await EnableCompleteReplacement(relationship, primaryResource); await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); @@ -281,12 +281,12 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); @@ -673,9 +673,25 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - private async Task TryGetPrimaryResource(TId id) + private async Task TryGetPrimaryResourceForCompleteReplacement(TId id) { - var primaryResource = await _dbContext.FindAsync(id); + TResource primaryResource; + + if (_targetedFields.Relationships.Any()) + { + var query = _dbContext.Set().Where(resource => resource.Id.Equals(id)); + foreach (var relationship in _targetedFields.Relationships) + { + query = query.Include(relationship.RelationshipPath); + } + + primaryResource = query.FirstOrDefault(); + } + else + { + primaryResource = await _dbContext.FindAsync(id); + } + if (primaryResource == null) { throw new DataStoreUpdateException(); From fbcdda2c9379ca2fbd377ce1132be76fc06d3f10 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 11:57:48 +0100 Subject: [PATCH 229/240] chore: add overload to DataStoreUpdateException --- .../Repositories/DataStoreUpdateException.cs | 6 ++++-- .../Repositories/EntityFrameworkCoreRepository.cs | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index 833c49a815..80d0d6e539 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -7,8 +7,10 @@ namespace JsonApiDotNetCore.Repositories /// public sealed class DataStoreUpdateException : Exception { - // TODO: Add second overload with message. - public DataStoreUpdateException(Exception exception = null) + public DataStoreUpdateException(Exception exception) : base("Failed to persist changes in the underlying data store.", exception) { } + + public DataStoreUpdateException(string message) + : base(message, null) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 2917bb9890..d6b14bbd2f 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -593,7 +593,7 @@ private async Task GetPrimaryResourceForCompleteReplacement(TId id, I if (primaryResource == null) { - throw new DataStoreUpdateException(); + throw new DataStoreUpdateException($"Resource of type {typeof(TResource)} with id ${id} does not exist."); } return primaryResource; @@ -606,7 +606,7 @@ private async Task AssertSecondaryResourcesExist(ISet secondaryRe if (secondaryResourcesFromDatabase.Count < secondaryResourceIds.Count) { - throw new DataStoreUpdateException(); + throw new DataStoreUpdateException($"One or more related resources of type {relationship.RightType} do not exist."); } } From f0e5a1f8827631ac8409c563182423bf1aa059cb Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 12:08:25 +0100 Subject: [PATCH 230/240] chore: renamed composite foreign key test to not repeat 'composite_foreign_key' in every test name because this is already in the test suite --- .../CompositeKeys/CompositeKeyTests.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index a3b5b7992d..b39f14c958 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -30,7 +30,7 @@ public CompositeKeyTests(IntegrationTestContext } [Fact] - public async Task Can_sort_on_composite_primary_key() + public async Task Can_sort_by_ID() { // Arrange var car = new Car @@ -117,7 +117,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_select_composite_primary_key() + public async Task Can_select_ID() { // Arrange var car = new Car @@ -146,7 +146,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_resource_with_composite_primary_key() + public async Task Can_create_resource() { // Arrange await _testContext.RunOnDatabaseAsync(async dbContext => @@ -179,7 +179,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_remove_ToOne_relationship_from_resource_with_composite_foreign_key() + public async Task Can_remove_OneToOne_relationship() { // Arrange var engine = new Engine @@ -235,9 +235,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_assign_ToOne_relationship_to_resource_with_composite_foreign_key() + public async Task Can_assign_OneToOne_relationship() { // Arrange var car = new Car @@ -300,7 +299,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_delete_resource_with_composite_primary_key() + public async Task Can_delete_resource() { // Arrange var car = new Car From 08e5978c68708cf9025cb5534a04f388d0193d55 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 12:12:47 +0100 Subject: [PATCH 231/240] tests: added new composite foreign key tests --- .../CompositeKeys/CompositeKeyTests.cs | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index b39f14c958..5718536b16 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -179,7 +179,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_remove_OneToOne_relationship() + public async Task Can_remove_OneToOne_relationship_with_composite_key_on_right_side() { // Arrange var engine = new Engine @@ -236,7 +236,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_assign_OneToOne_relationship() + public async Task Can_assign_OneToOne_relationship_with_composite_key_on_right_side() { // Arrange var car = new Car @@ -298,6 +298,76 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact(Skip = "TODO: Write this test")] + public async Task Can_remove_from_OneToMany_relationship_with_composite_key_on_right_side() + { + // Arrange + var dealership = new Journey() + { + Destination = "Amsterda, the Netherlands", + Car = + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(dealership); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "dealerships", + id = dealership.StringId, + relationships = new + { + cars = new + { + data = new + { + type = "car", + id = "123:AA-BB-11" + } + } + } + } + }; + + var route = "/dealerships/" + dealership.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var dealershipInDatabase = await dbContext.Dealerships + .Include(d => dealership.Cars) + .FirstOrDefaultAsync(d => d.Id == dealership.Id); + + dealershipInDatabase.Should().NotBeNull(); + dealershipInDatabase.Cars.Should().ContainSingle(car => car.Id == dealership.Cars.ElementAt(1).Id); + }); + } + + [Fact(Skip = "TODO: Write this test")] + public async Task Can_add_to_OneToMany_relationship_with_composite_key_on_right_side() + { + + } + + [Fact(Skip = "TODO: Write this test")] + public async Task Cannot_add_to_ManyToOne_relationship_with_composite_key_on_left_side_for_missing_relationship_ID() + { + + } + [Fact] public async Task Can_delete_resource() { From 480cf3069fe97f4ac7cfac9882cd843092615087 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 13:19:45 +0100 Subject: [PATCH 232/240] test: composite foreign key relationship tests --- .../EntityFrameworkCoreRepository.cs | 4 +- .../ResourceRepositoryAccessor.cs | 2 +- .../IntegrationTests/CompositeKeys/Car.cs | 4 +- .../CompositeKeys/CarRepository.cs | 1 + .../CompositeKeys/CompositeDbContext.cs | 8 +- .../CompositeKeys/CompositeKeyTests.cs | 304 ++++++++++++++---- .../{Journey.cs => Dealership.cs} | 7 +- ...Controller.cs => DealershipsController.cs} | 6 +- .../RemoveFromToManyRelationshipTests.cs | 4 - 9 files changed, 253 insertions(+), 87 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/{Journey.cs => Dealership.cs} (60%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/{JourneysController.cs => DealershipsController.cs} (57%) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d6b14bbd2f..d312927ee3 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -298,11 +298,11 @@ private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, T // Ensures the new relationship assignment will not result in entities being tracked more than once. var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - // TODO: Similar to like the EnableCompleteReplacement performance related todo item, we shouldn't have to load the inversely related entity into memory. Clearing any existing relation is enough. if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) { var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; + // TODO: Just clearing the relationship should be enough rather than first loading the related entity in memory. await entityEntry.Reference(inversePropertyName).LoadAsync(); } @@ -608,6 +608,8 @@ private async Task AssertSecondaryResourcesExist(ISet secondaryRe { throw new DataStoreUpdateException($"One or more related resources of type {relationship.RightType} do not exist."); } + + DetachEntities(secondaryResourcesFromDatabase.ToArray()); } private void DetachRelationships(IIdentifiable resource) diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index a324067ba6..2ab7c307c8 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -38,7 +38,7 @@ public async Task> GetAsync(Type resourceType private object GetRepository(TResource _, TId __) where TResource : class, IIdentifiable { - return _serviceProvider.GetRequiredService>(); + return _serviceProvider.GetRequiredService>(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs index 0c05b0c5e2..96a6fb42ac 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs @@ -39,7 +39,7 @@ public override string Id [HasOne] public Engine Engine { get; set; } - [HasMany] - public ISet Journeys { get; set; } + [HasOne] + public Dealership Dealership { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs index 671164b31f..54d16bc1df 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -10,6 +10,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { + // TODO: Fix auto-discovery of services. CarRepository is only registered as IResourceRepository, not IResourceReadRepository and IResourceWriteRepository. public sealed class CarRepository : EntityFrameworkCoreRepository { private readonly IResourceGraph _resourceGraph; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index d22a2b54b1..d360aaff0d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -8,7 +8,7 @@ public sealed class CompositeDbContext : DbContext public DbSet Engines { get; set; } - public DbSet Journeys { get; set; } + public DbSet Dealerships { get; set; } public CompositeDbContext(DbContextOptions options) : base(options) @@ -25,9 +25,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(c => c.Engine) .HasForeignKey(); - modelBuilder.Entity() - .HasOne(e => e.Car) - .WithMany(c => c.Journeys); + modelBuilder.Entity() + .HasMany(e => e.Inventory) + .WithOne(c => c.Dealership); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 5718536b16..3e299ff4b9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -24,7 +24,7 @@ public CompositeKeyTests(IntegrationTestContext, CarRepository>(); }); - + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; } @@ -149,10 +149,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); var requestBody = new { @@ -179,23 +176,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_remove_OneToOne_relationship_with_composite_key_on_right_side() + public async Task Can_create_OneToOne_relationship() { // Arrange - var engine = new Engine + var existingCar = new Car { - SerialCode = "1234567890", - Car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - } + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + var existingEngine = new Engine + { + SerialCode = "1234567890" }; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Engines.Add(engine); + dbContext.AddRange(existingCar, existingEngine); await dbContext.SaveChangesAsync(); }); @@ -204,18 +202,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "engines", - id = engine.StringId, + id = existingEngine.StringId, relationships = new { car = new { - data = (object) null + data = new + { + type = "cars", + id = existingCar.StringId + } } } } }; - var route = "/engines/" + engine.StringId; + var route = "/engines/" + existingEngine.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -228,32 +230,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines - .Include(e => e.Car) - .FirstAsync(e => e.Id == engine.Id); + .Include(engine => engine.Car) + .FirstAsync(engine => engine.Id == existingEngine.Id); - engineInDatabase.Car.Should().BeNull(); + engineInDatabase.Car.Should().NotBeNull(); + engineInDatabase.Car.Id.Should().Be(existingCar.StringId); }); } [Fact] - public async Task Can_assign_OneToOne_relationship_with_composite_key_on_right_side() + public async Task Can_clear_OneToOne_relationship() { // Arrange - var car = new Car + var existingEngine = new Engine { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - var engine = new Engine - { - SerialCode = "1234567890" + SerialCode = "1234567890", + Car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + } }; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.AddRange(car, engine); + dbContext.Engines.Add(existingEngine); await dbContext.SaveChangesAsync(); }); @@ -262,22 +264,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "engines", - id = engine.StringId, + id = existingEngine.StringId, relationships = new { car = new { - data = new - { - type = "cars", - id = car.StringId - } + data = (object) null } } } }; - var route = "/engines/" + engine.StringId; + var route = "/engines/" + existingEngine.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -290,52 +288,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var engineInDatabase = await dbContext.Engines - .Include(e => e.Car) - .FirstAsync(e => e.Id == engine.Id); + .Include(engine => engine.Car) + .FirstAsync(engine => engine.Id == existingEngine.Id); - engineInDatabase.Car.Should().NotBeNull(); - engineInDatabase.Car.Id.Should().Be(car.StringId); + engineInDatabase.Car.Should().BeNull(); }); } - [Fact(Skip = "TODO: Write this test")] - public async Task Can_remove_from_OneToMany_relationship_with_composite_key_on_right_side() + [Fact] + public async Task Can_remove_from_OneToMany_relationship() { // Arrange - var dealership = new Journey() + var existingDealership = new Dealership() { - Destination = "Amsterda, the Netherlands", - Car = + Destination = "Amsterdam, the Netherlands", + Inventory = new HashSet + { + new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }, + new Car + { + RegionId = 456, + LicensePlate = "CC-DD-22" + } + } }; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Dealerships.Add(dealership); + dbContext.Dealerships.Add(existingDealership); await dbContext.SaveChangesAsync(); }); + var requestBody = new { - data = new - { - type = "dealerships", - id = dealership.StringId, - relationships = new + data = new [] + { new { - cars = new - { - data = new - { - type = "car", - id = "123:AA-BB-11" - } - } + type = "cars", + id = "123:AA-BB-11" } } }; - var route = "/dealerships/" + dealership.StringId; + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); @@ -348,24 +349,189 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var dealershipInDatabase = await dbContext.Dealerships - .Include(d => dealership.Cars) - .FirstOrDefaultAsync(d => d.Id == dealership.Id); + .Include(dealership => dealership.Inventory) + .FirstOrDefaultAsync(dealership => dealership.Id == existingDealership.Id); + + dealershipInDatabase.Inventory.Should().HaveCount(1); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + var existingDealership = new Dealership() + { + Destination = "Amsterdam, the Netherlands" + }; + var existingCar = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingDealership, existingCar); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new [] + { new + { + type = "cars", + id = "123:AA-BB-11" + } + } + }; + + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var dealershipInDatabase = await dbContext.Dealerships + .Include(dealership => dealership.Inventory) + .FirstOrDefaultAsync(dealership => dealership.Id == existingDealership.Id); - dealershipInDatabase.Should().NotBeNull(); - dealershipInDatabase.Cars.Should().ContainSingle(car => car.Id == dealership.Cars.ElementAt(1).Id); + dealershipInDatabase.Inventory.Should().HaveCount(1); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); }); } - [Fact(Skip = "TODO: Write this test")] - public async Task Can_add_to_OneToMany_relationship_with_composite_key_on_right_side() + [Fact] + public async Task Can_replace_OneToMany_relationship() { + // Arrange + var existingDealership = new Dealership() + { + Destination = "Amsterdam, the Netherlands", + Inventory = new HashSet + { + new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }, + new Car + { + RegionId = 456, + LicensePlate = "CC-DD-22" + } + } + }; + var existingCar = new Car + { + RegionId = 789, + LicensePlate = "EE-FF-33" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingDealership, existingCar); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new [] + { + new + { + type = "cars", + id = "123:AA-BB-11" + }, + new + { + type = "cars", + id = "789:EE-FF-33" + }, + + } + }; + + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var dealershipInDatabase = await dbContext.Dealerships + .Include(dealership => dealership.Inventory) + .FirstOrDefaultAsync(dealership => dealership.Id == existingDealership.Id); + + dealershipInDatabase.Inventory.Should().HaveCount(2); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(0).Id); + }); } - [Fact(Skip = "TODO: Write this test")] - public async Task Cannot_add_to_ManyToOne_relationship_with_composite_key_on_left_side_for_missing_relationship_ID() + [Fact] + public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() { + // Arrange + var dealership = new Dealership() + { + Destination = "Amsterdam, the Netherlands", + Inventory = new HashSet + { + new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(dealership); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new [] + { new + { + type = "cars", + id = "999:XX-YY-22" + } + } + }; + + var route = $"/dealerships/{dealership.StringId}/relationships/inventory"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'cars' with ID '999:XX-YY-22' in relationship 'inventory' does not exist."); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Journey.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs similarity index 60% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Journey.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs index 100bd4ed6c..5c4b4d3013 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Journey.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -1,14 +1,15 @@ +using System.Collections.Generic; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { - public sealed class Journey : Identifiable + public sealed class Dealership : Identifiable { [Attr] public string Destination { get; set; } - [HasOne] - public Car Car { get; set; } + [HasMany] + public ISet Inventory { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/JourneysController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs similarity index 57% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/JourneysController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs index 2287874053..53b4f281e1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/JourneysController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { - public sealed class JourneysController : JsonApiController + public sealed class DealershipsController : JsonApiController { - public JourneysController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + public DealershipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 409bf0161f..e342229c51 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -362,10 +362,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(2); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - // TODO: Make error message more general so that it can be reused for this case. - // Proposed: A referenced secondary resource does not exist. - // => "A related resource does not exist." - // => "Related resource of type 'workItems' with ID '12345678' in relationship 'assignedItems' does not exist." responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '88888888' in relationship 'subscribers' does not exist."); From 2eac821bcb157190120f9af75d2da55210744f50 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 16:08:28 +0100 Subject: [PATCH 233/240] chore: review, revert accessor --- .../EntityFrameworkCoreRepository.cs | 33 ++++--------------- .../ResourceRepositoryAccessor.cs | 28 ++++++++++------ .../Services/GetResourcesByIds.cs | 1 + .../Services/JsonApiResourceService.cs | 14 -------- .../CompositeKeys/CarRepository.cs | 1 - 5 files changed, 26 insertions(+), 51 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d312927ee3..11577f6140 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -295,14 +295,13 @@ private async Task SaveChangesAsync() private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { - // Ensures the new relationship assignment will not result in entities being tracked more than once. var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) { var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; - // TODO: Just clearing the relationship should be enough rather than first loading the related entity in memory. + await entityEntry.Reference(inversePropertyName).LoadAsync(); } @@ -339,23 +338,13 @@ private void FlushFromCache(IIdentifiable resource) resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); if (resource != null) { - DetachEntities(resource); + DetachEntities(new [] { resource }); DetachRelationships(resource); } } private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) { - // // Unfortunately this does not work because we cannot guarantee a inverse navigation to exist. - // - // var inverseRelationship = _resourceGraph.GetResourceContext(relationship.RightType).Relationships.FirstOrDefault(rightRelationship => - // rightRelationship is HasManyThroughAttribute rightHasManyThrough && - // rightHasManyThrough.ThroughType == relationship.ThroughType); - // - // var typedIds = secondaryResourceIds.Select(resource => resource.GetTypedId()); - // var rightResources = await _getResourcesByIdsService.Get(relationship.RightType, typedIds, inverseRelationship); - - // TODO: Finalize this. var throughEntitiesFilter = new ThroughEntitiesFilter(_dbContext, relationship); var typedRightIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); @@ -481,7 +470,7 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio relationship.SetValue(leftResource, placeholderRightResource); _dbContext.Entry(leftResource).DetectChanges(); - DetachEntities(placeholderRightResource); + DetachEntities(new [] { placeholderRightResource }); } private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) @@ -553,14 +542,6 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - // TODO: This does not perform well. Currently related entities are loaded into memory, - // and when SaveChangesAsync() is called later in the pipeline, the following happens: - // - FKs of records that need to be detached are nulled out one by one, one query each (or the join table entries are deleted one by one in case of many-to-many). - // - FKs records that need to be attached are updated one by one (or join table entries are created one by one). - // Possible approaches forward: - // - Writing raw sql to get around this. - // - Throw when a certain limit of update statements is reached to ensure the developer is aware of these performance issues. - // - Include a 3rd party library that handles batching. /// /// Gets the primary resource by id and performs side-loading of data such that EF Core correctly performs complete replacements of relationships. /// @@ -593,7 +574,7 @@ private async Task GetPrimaryResourceForCompleteReplacement(TId id, I if (primaryResource == null) { - throw new DataStoreUpdateException($"Resource of type {typeof(TResource)} with id ${id} does not exist."); + throw new DataStoreUpdateException($"Resource of type '{typeof(TResource)}' with id '{id}' does not exist."); } return primaryResource; @@ -606,7 +587,7 @@ private async Task AssertSecondaryResourcesExist(ISet secondaryRe if (secondaryResourcesFromDatabase.Count < secondaryResourceIds.Count) { - throw new DataStoreUpdateException($"One or more related resources of type {relationship.RightType} do not exist."); + throw new DataStoreUpdateException($"One or more related resources of type '{relationship.RightType}' do not exist."); } DetachEntities(secondaryResourcesFromDatabase.ToArray()); @@ -624,13 +605,13 @@ private void DetachRelationships(IIdentifiable resource) } else if (rightValue != null) { - DetachEntities(rightValue); + DetachEntities(new [] { rightValue }); _dbContext.Entry(rightValue).State = EntityState.Detached; } } } - private void DetachEntities(params object[] entities) + private void DetachEntities(IEnumerable entities) { foreach (var entity in entities) { diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 2ab7c307c8..63644f301f 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -13,13 +13,11 @@ public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { private readonly IServiceProvider _serviceProvider; private readonly IResourceContextProvider _resourceContextProvider; - private readonly IResourceFactory _resourceFactory; - public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) + public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentException(nameof(serviceProvider)); - _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); } /// @@ -28,17 +26,27 @@ public async Task> GetAsync(Type resourceType if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); if (layer == null) throw new ArgumentNullException(nameof(layer)); - dynamic runtimeResourceTypeParameter = _resourceFactory.CreateInstance(resourceType); - dynamic runtimeIdTypeParameter = ((IIdentifiable)runtimeResourceTypeParameter).GetTypedId(); - - dynamic repository = GetRepository(runtimeResourceTypeParameter, runtimeIdTypeParameter); - + dynamic repository = GetRepository(resourceType); return (IReadOnlyCollection) await repository.GetAsync(layer); } - private object GetRepository(TResource _, TId __) where TResource : class, IIdentifiable + protected object GetRepository(Type resourceType) { - return _serviceProvider.GetRequiredService>(); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + + if (resourceContext.IdentityType == typeof(int)) + { + var intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceContext.ResourceType); + var intRepository = _serviceProvider.GetService(intRepositoryType); + + if (intRepository != null) + { + return intRepository; + } + } + + var resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); } } } diff --git a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs index 9d39c58c19..cab28770de 100644 --- a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs +++ b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs @@ -11,6 +11,7 @@ namespace JsonApiDotNetCore.Services { + // TODO: Reconsider responsibilities (IQueryLayerComposer?) /// public class GetResourcesByIds : IGetResourcesByIds { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 64bb0ea572..154c9cf3c6 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -514,22 +514,8 @@ private void AssertRelationshipIsToMany() } } - // TODO: two dimensions: preserve clients: yes or no, and attributes: add all or id only private enum TopFieldSelection { - // TODO: Consider using flags. We have two degrees of freedom: client selection => ignore/preserve and attributes => only-id/all. Is this a use-case where flags make sense? - // PreserveClientSelection = 1 << 0, - // IgnoreClientSelection = 1 << 1, - // AllAttributes = 1 << 2, - // OnlyIdAttribute = 1 << 3 - // - // usage: - // old new - // PreserveExisting --> PreserveClientSelection - // WithAllAttributes --> PreserveClientSelection | AllAttributes - // OnlyAllAttributes --> IgnoreClientSelection | AllAttributes - // OnlyIdAttribute --> IgnoreClientSelection | OnlyIdAttribute - /// /// Discards any included relationships and selects all resource attributes. /// diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs index 54d16bc1df..671164b31f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -10,7 +10,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { - // TODO: Fix auto-discovery of services. CarRepository is only registered as IResourceRepository, not IResourceReadRepository and IResourceWriteRepository. public sealed class CarRepository : EntityFrameworkCoreRepository { private readonly IResourceGraph _resourceGraph; From c7312813fc5287ac17cb9f26946e38932cc24157 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 16:43:39 +0100 Subject: [PATCH 234/240] chore: rename Destination to Address in Dealership, fix IResourceReadRepository registration in car repository test registration, add OnlyAllAttributes topfield selection usage --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 9 +++++++-- .../IntegrationTests/CompositeKeys/CompositeKeyTests.cs | 9 +++++---- .../IntegrationTests/CompositeKeys/Dealership.cs | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 154c9cf3c6..bc0314d94b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -263,7 +263,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _hookExecutor.BeforeUpdateResource(resourceFromRequest); - TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); + TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.OnlyAllAttributes); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); @@ -311,7 +311,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); await _hookExecutor.BeforeUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceById(id, TopFieldSelection.OnlyAllAttributes)); try { @@ -403,6 +403,11 @@ private async Task TryGetPrimaryResourceById(TId id, TopFieldSelectio primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); } } + else if (fieldSelection == TopFieldSelection.OnlyAllAttributes) + { + primaryLayer.Include = null; + primaryLayer.Projection = null; + } var primaryResources = await _repository.GetAsync(primaryLayer); return primaryResources.SingleOrDefault(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 3e299ff4b9..52bb39df35 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -23,6 +23,7 @@ public CompositeKeyTests(IntegrationTestContext { services.AddScoped, CarRepository>(); + services.AddScoped, CarRepository>(); }); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); @@ -301,7 +302,7 @@ public async Task Can_remove_from_OneToMany_relationship() // Arrange var existingDealership = new Dealership() { - Destination = "Amsterdam, the Netherlands", + Address = "Dam 1, 1012JS Amsterdam, the Netherlands", Inventory = new HashSet { new Car @@ -363,7 +364,7 @@ public async Task Can_add_to_OneToMany_relationship() // Arrange var existingDealership = new Dealership() { - Destination = "Amsterdam, the Netherlands" + Address = "Dam 1, 1012JS Amsterdam, the Netherlands" }; var existingCar = new Car { @@ -416,7 +417,7 @@ public async Task Can_replace_OneToMany_relationship() // Arrange var existingDealership = new Dealership() { - Destination = "Amsterdam, the Netherlands", + Address = "Dam 1, 1012JS Amsterdam, the Netherlands", Inventory = new HashSet { new Car @@ -490,7 +491,7 @@ public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relation // Arrange var dealership = new Dealership() { - Destination = "Amsterdam, the Netherlands", + Address = "Dam 1, 1012JS Amsterdam, the Netherlands", Inventory = new HashSet { new Car diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs index 5c4b4d3013..b8c845dc7c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys public sealed class Dealership : Identifiable { [Attr] - public string Destination { get; set; } + public string Address { get; set; } [HasMany] public ISet Inventory { get; set; } From 596a331982ad13c4c4c8a73adcd574d13eac9286 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 4 Nov 2020 16:58:51 +0100 Subject: [PATCH 235/240] cleanup --- .../InverseRelationshipResolver.cs | 3 +- .../Repositories/DataStoreUpdateException.cs | 6 +- .../EntityFrameworkCoreRepository.cs | 19 +++-- .../Repositories/IResourceWriteRepository.cs | 2 +- .../Internal/ThroughEntitiesFilter.cs | 2 +- .../Resources/ITargetedFields.cs | 4 +- .../Services/GetResourcesByIds.cs | 1 + .../Services/JsonApiResourceService.cs | 2 +- .../EntityFrameworkCoreRepositoryTests.cs | 11 +-- .../IntegrationTests/CompositeKeys/Car.cs | 1 - .../CompositeKeys/CompositeDbContext.cs | 14 ++-- .../CompositeKeys/CompositeKeyTests.cs | 70 ++++++++++--------- 12 files changed, 66 insertions(+), 69 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs index 26dfc028a9..12491d38cd 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -40,7 +39,7 @@ private void Resolve(DbContext dbContext) { foreach (var relationship in resourceContext.Relationships) { - if ( !(relationship is HasManyThroughAttribute)) + if (!(relationship is HasManyThroughAttribute)) { INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.FindInverse(); relationship.InverseNavigationProperty = inverseNavigation?.PropertyInfo; diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index 80d0d6e539..1204a9fe0d 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -7,10 +7,10 @@ namespace JsonApiDotNetCore.Repositories /// public sealed class DataStoreUpdateException : Exception { - public DataStoreUpdateException(Exception exception) + public DataStoreUpdateException(Exception exception) : base("Failed to persist changes in the underlying data store.", exception) { } - public DataStoreUpdateException(string message) - : base(message, null) { } + public DataStoreUpdateException(string message) + : base(message) { } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index d312927ee3..c5361fe596 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -199,23 +199,22 @@ public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceI } /// - public virtual async Task UpdateAsync(TResource resourceFromRequest) + public virtual async Task UpdateAsync(TResource resource) { - _traceWriter.LogMethodStart(new {resourceFromRequest}); - if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); - var resourceFromDatabase = await GetPrimaryResourceForCompleteReplacement(resourceFromRequest.Id, _targetedFields.Relationships); + var resourceFromDatabase = await GetPrimaryResourceForCompleteReplacement(resource.Id, _targetedFields.Relationships); foreach (var relationship in _targetedFields.Relationships) { - - var rightResources = relationship.GetValue(resourceFromRequest); + var rightResources = relationship.GetValue(resource); await ApplyRelationshipUpdate(relationship, resourceFromDatabase, rightResources); } foreach (var attribute in _targetedFields.Attributes) { - attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resource)); } await SaveChangesAsync(); @@ -376,8 +375,8 @@ private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity) var relationship = (HasManyThroughAttribute)_targetedFields.Relationships.Single(); var rightResource = _resourceFactory.CreateInstance(relationship.RightType); - rightResource.StringId = relationship.RightIdProperty.GetValue(entity)!.ToString(); - + rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); + return rightResource; } @@ -576,7 +575,7 @@ private async Task GetPrimaryResourceForCompleteReplacement(TId id, I { TResource primaryResource; - if (relationships?.Any() == true) + if (relationships.Any()) { var query = _dbContext.Set().Where(resource => resource.Id.Equals(id)); foreach (var relationship in relationships) diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index c6dbde9ed8..db6fa3a8c5 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -31,7 +31,7 @@ public interface IResourceWriteRepository /// /// Updates the attributes and relationships of an existing resource in the underlying data store. /// - Task UpdateAsync(TResource resourceFromRequest); + Task UpdateAsync(TResource resource); /// /// Performs a complete replacement of the relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs b/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs index acb47b6151..aec79b170d 100644 --- a/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs +++ b/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs @@ -5,12 +5,12 @@ using System.Reflection; using System.Threading.Tasks; using Humanizer; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Repositories.Internal { + // TODO: Refactor this type (it is a helper method). internal sealed class ThroughEntitiesFilter { private readonly DbContext _dbContext; diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs index e3b8d2221b..5cdb36950d 100644 --- a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCore.Resources public interface ITargetedFields { /// - /// List of attributes that are targeted by a request. + /// The set of attributes that are targeted by a request. /// ISet Attributes { get; set; } /// - /// List of relationships that are targeted by a request. + /// The set of relationships that are targeted by a request. /// ISet Relationships { get; set; } } diff --git a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs index 9d39c58c19..4a82760c3a 100644 --- a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs +++ b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs @@ -12,6 +12,7 @@ namespace JsonApiDotNetCore.Services { /// + // TODO: Refactor this type (it is a helper method). public class GetResourcesByIds : IGetResourcesByIds { private readonly IResourceGraph _resourceGraph; diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 64bb0ea572..b0fda7004d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -263,6 +263,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _hookExecutor.BeforeUpdateResource(resourceFromRequest); + // TODO: Call with OnlyAllAttributes (impl: clear all projections => selects all fields, no includes and all eager-loads) TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); @@ -277,7 +278,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - // TODO: Call with OnlyAllAttributes (impl: clear all projections => selects all fields, no includes and all eager-loads) TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 400a5fdced..5fac2caabe 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -90,14 +90,9 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); var targetedFields = new Mock(); var getResourcesByIds = new Mock().Object; - var repository = new EntityFrameworkCoreRepository( - targetedFields.Object, - contextResolverMock.Object, - resourceGraph, - resourceFactory, - new List(), - getResourcesByIds, - NullLoggerFactory.Instance); + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, + contextResolverMock.Object, resourceGraph, resourceFactory, new List(), + getResourcesByIds, NullLoggerFactory.Instance); return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs index 96a6fb42ac..70591f317f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index d360aaff0d..1a9017472e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -5,12 +5,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys public sealed class CompositeDbContext : DbContext { public DbSet Cars { get; set; } - public DbSet Engines { get; set; } - public DbSet Dealerships { get; set; } - public CompositeDbContext(DbContextOptions options) + public CompositeDbContext(DbContextOptions options) : base(options) { } @@ -18,16 +16,16 @@ public CompositeDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() - .HasKey(c => new {c.RegionId, c.LicensePlate}); + .HasKey(car => new {car.RegionId, car.LicensePlate}); modelBuilder.Entity() - .HasOne(e => e.Car) - .WithOne(c => c.Engine) + .HasOne(engine => engine.Car) + .WithOne(car => car.Engine) .HasForeignKey(); modelBuilder.Entity() - .HasMany(e => e.Inventory) - .WithOne(c => c.Dealership); + .HasMany(dealership => dealership.Inventory) + .WithOne(car => car.Dealership); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 3e299ff4b9..9fbe2109b7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -12,7 +12,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { - public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> + public sealed class CompositeKeyTests + : IClassFixture, CompositeDbContext>> { private readonly IntegrationTestContext, CompositeDbContext> _testContext; @@ -30,7 +31,7 @@ public CompositeKeyTests(IntegrationTestContext } [Fact] - public async Task Can_sort_by_ID() + public async Task Can_sort_on_ID() { // Arrange var car = new Car @@ -149,7 +150,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); var requestBody = new { @@ -299,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership() + var existingDealership = new Dealership { Destination = "Amsterdam, the Netherlands", Inventory = new HashSet @@ -324,11 +328,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var requestBody = new { - data = new [] - { new + data = new[] + { + new { type = "cars", id = "123:AA-BB-11" @@ -361,7 +365,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership() + var existingDealership = new Dealership { Destination = "Amsterdam, the Netherlands" }; @@ -380,8 +384,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = new [] - { new + data = new[] + { + new { type = "cars", id = "123:AA-BB-11" @@ -414,7 +419,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership() + var existingDealership = new Dealership { Destination = "Amsterdam, the Netherlands", Inventory = new HashSet @@ -446,8 +451,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = new [] - { + data = new[] + { new { type = "cars", @@ -457,7 +462,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "cars", id = "789:EE-FF-33" - }, + } } }; @@ -488,30 +493,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() { // Arrange - var dealership = new Dealership() + var existingDealership = new Dealership { - Destination = "Amsterdam, the Netherlands", - Inventory = new HashSet - { - new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - } - } + Destination = "Amsterdam, the Netherlands" }; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Dealerships.Add(dealership); + dbContext.Dealerships.Add(existingDealership); await dbContext.SaveChangesAsync(); }); var requestBody = new { - data = new [] - { new + data = new[] + { + new { type = "cars", id = "999:XX-YY-22" @@ -519,7 +517,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/dealerships/{dealership.StringId}/relationships/inventory"; + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); @@ -538,7 +536,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource() { // Arrange - var car = new Car + var existingCar = new Car { RegionId = 123, LicensePlate = "AA-BB-11" @@ -547,11 +545,11 @@ public async Task Can_delete_resource() await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); + dbContext.Cars.Add(existingCar); await dbContext.SaveChangesAsync(); }); - var route = "/cars/" + car.StringId; + var route = "/cars/" + existingCar.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); @@ -560,6 +558,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var carInDatabase = await dbContext.Cars + .FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); + + carInDatabase.Should().BeNull(); + }); } } } From 896af96346a3efc96aeadc83d11d6253f7375ee4 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 17:20:55 +0100 Subject: [PATCH 236/240] fix: revert unneccesary changes, add validation test for document parser --- .../InverseRelationshipResolver.cs | 3 +-- .../Serialization/BaseDeserializer.cs | 6 +++++ .../Services/JsonApiResourceService.cs | 3 +-- .../Common/DocumentParserTests.cs | 27 ++++++++++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs index 26dfc028a9..12491d38cd 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -40,7 +39,7 @@ private void Resolve(DbContext dbContext) { foreach (var relationship in resourceContext.Relationships) { - if ( !(relationship is HasManyThroughAttribute)) + if (!(relationship is HasManyThroughAttribute)) { INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.FindInverse(); relationship.InverseNavigationProperty = inverseNavigation?.PropertyInfo; diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 2d06b586c3..423dc2cdeb 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -186,6 +186,12 @@ private void SetHasOneRelationship(IIdentifiable resource, HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) { + if (relationshipData.ManyData != null) + { + throw new JsonApiSerializationException("Expected single data for to-one relationship.", + $"Expected single data for '{hasOneRelationship.PublicName}' relationship."); + } + var rio = (ResourceIdentifierObject)relationshipData.Data; var relatedId = rio?.Id; diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index bc0314d94b..985e00384c 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -277,7 +277,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - // TODO: Call with OnlyAllAttributes (impl: clear all projections => selects all fields, no includes and all eager-loads) TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); @@ -311,7 +310,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); await _hookExecutor.BeforeUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.OnlyAllAttributes)); + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); try { diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index fcaa24a14e..2d02c60c51 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -3,6 +3,7 @@ using System.ComponentModel.Design; using System.Linq; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; using UnitTests.TestModels; @@ -215,6 +216,30 @@ public void DeserializeAttributes_ComplexListType_CanDeserialize() Assert.Equal("testName", result.ComplexFields[0].CompoundName); } + [Fact] + public void DeserializeRelationship_SingleDataForToOneRelationship_CannotDeserialize() + { + // Arrange + var content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents"); + content.SingleData.Relationships["dependents"] = new RelationshipEntry { Data = new ResourceIdentifierObject { Type = "Dependents", Id = "1" } }; + var body = JsonConvert.SerializeObject(content); + + // Act, assert + Assert.Throws(() => _deserializer.Deserialize(body)); + } + + [Fact] + public void DeserializeRelationship_ManyDataForToManyRelationship_CannotDeserialize() + { + // Arrange + var content = CreateDocumentWithRelationships("oneToOnePrincipals", "dependent"); + content.SingleData.Relationships["dependent"] = new RelationshipEntry { Data = new List { new ResourceIdentifierObject { Type = "Dependent", Id = "1" } }}; + var body = JsonConvert.SerializeObject(content); + + // Act, assert + Assert.Throws(() => _deserializer.Deserialize(body)); + } + [Fact] public void DeserializeRelationships_EmptyOneToOneDependent_NavigationPropertyIsNull() { @@ -263,7 +288,7 @@ public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationIsNull() } [Fact] - public void DeserializeRelationships_EmptyRequiredOneToOnePrincipal_ThrowsFormatException() + public void DeserializeRelationships_EmptyRequiredOneToOnePrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToOneRequiredDependents", "principal"); From 3f156186a9c776ad36eeb66db563b27e4cb9ac87 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 18:02:50 +0100 Subject: [PATCH 237/240] test: add clear one-to-one --- .../IntegrationTests/Writing/RgbColor.cs | 1 + .../Resources/UpdateToOneRelationshipTests.cs | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index 0b260fb0df..e6915211a8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index d0d505b355..783f94bab7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -197,6 +197,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_clear_OneToOne_relationship() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingGroup.Color.StringId, + relationships = new + { + group = new + { + data = (object)null + } + } + } + }; + + var route = "/rgbColors/" + existingGroup.Color.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .Include(group => group.Color) + .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + + groupInDatabase.Color.Should().BeNull(); + }); + } + [Fact] public async Task Can_replace_ManyToOne_relationship() { From edccb594154e14881e31f8319ebad901bfbd577a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 4 Nov 2020 18:06:14 +0100 Subject: [PATCH 238/240] post-merge fixes --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 +- .../IntegrationTests/CompositeKeys/CompositeKeyTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index e4de4fb2d1..985e00384c 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -310,7 +310,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); await _hookExecutor.BeforeUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.OnlyAllAttributes)); // was: WithAllAttributes + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); try { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 9c7872a066..b40a17fd7e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -24,6 +24,7 @@ public CompositeKeyTests(IntegrationTestContext { services.AddScoped, CarRepository>(); + services.AddScoped, CarRepository>(); }); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); From 73285ddc89d152a9095403cbd1eb1ae06a4bea68 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 4 Nov 2020 18:09:37 +0100 Subject: [PATCH 239/240] changed to offset for better coverage --- .../IntegrationTests/Writing/WorkItem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs index 7c9fa34892..aacfd8613a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs @@ -12,7 +12,7 @@ public sealed class WorkItem : Identifiable public string Description { get; set; } [Attr] - public DateTime? DueAt { get; set; } + public DateTimeOffset? DueAt { get; set; } [Attr] public WorkItemPriority Priority { get; set; } From cec81044cb8b7be9c5ea95f4b602f29c0931df12 Mon Sep 17 00:00:00 2001 From: maurei Date: Wed, 4 Nov 2020 18:30:10 +0100 Subject: [PATCH 240/240] test: add clearing one-to-one tests --- .../UpdateToOneRelationshipTests.cs | 38 +++++++++++++++++++ .../Resources/UpdateToOneRelationshipTests.cs | 18 ++++----- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index 209047e700..8534fc0e5d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -57,6 +57,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_clear_OneToOne_relationship() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItemGroups/{existingGroup.StringId}/relationships/color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .Include(group => group.Color) + .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + + groupInDatabase.Color.Should().BeNull(); + }); + } + [Fact] public async Task Can_create_OneToOne_relationship_from_dependent_side() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index 783f94bab7..f018fd6cbe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -201,12 +201,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_OneToOne_relationship() { // Arrange - var existingGroup = _fakers.WorkItemGroup.Generate(); - existingGroup.Color = _fakers.RgbColor.Generate(); + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Groups.AddRange(existingGroup); + dbContext.RgbColors.AddRange(existingColor); await dbContext.SaveChangesAsync(); }); @@ -215,7 +215,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "rgbColors", - id = existingGroup.Color.StringId, + id = existingColor.StringId, relationships = new { group = new @@ -226,7 +226,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/rgbColors/" + existingGroup.Color.StringId; + var route = "/rgbColors/" + existingColor.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -238,11 +238,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var groupInDatabase = await dbContext.Groups - .Include(group => group.Color) - .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + var colorInDatabase = await dbContext.RgbColors + .Include(color => color.Group) + .FirstOrDefaultAsync(color => color.Id == existingColor.Id); - groupInDatabase.Color.Should().BeNull(); + colorInDatabase.Group.Should().BeNull(); }); }