diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 6e6debd819..2951a40bbb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; @@ -7,8 +8,24 @@ namespace JsonApiDotNetCoreExample.Models { public class Passport : Identifiable { + private int? _socialSecurityNumber; + + [Attr] + public int? SocialSecurityNumber + { + get => _socialSecurityNumber; + set + { + if (value != _socialSecurityNumber) + { + LastSocialSecurityNumberChange = DateTime.Now; + _socialSecurityNumber = value; + } + } + } + [Attr] - public int? SocialSecurityNumber { get; set; } + public DateTime LastSocialSecurityNumberChange { get; set; } [Attr] public bool IsLocked { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 94090b9c22..cde361fbb9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Links; @@ -12,10 +13,26 @@ public sealed class PersonRole : Identifiable public sealed class Person : Identifiable, IIsLockable { + private string _firstName; + public bool IsLocked { get; set; } [Attr] - public string FirstName { get; set; } + public string FirstName + { + get => _firstName; + set + { + if (value != _firstName) + { + _firstName = value; + Initials = string.Concat(value.Split(' ').Select(x => char.ToUpperInvariant(x[0]))); + } + } + } + + [Attr] + public string Initials { get; set; } [Attr] public string LastName { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 5358d6cb11..8566ac80c1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -22,6 +22,13 @@ public TodoItem() [Attr] public Guid GuidProperty { get; set; } + [Attr] + public string AlwaysChangingValue + { + get => Guid.NewGuid().ToString(); + set { } + } + [Attr] public DateTime CreatedDate { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index ef65054c89..6c285140c6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -1,11 +1,29 @@ +using System; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCoreExample.Models { public class User : Identifiable { + private string _password; + [Attr] public string Username { get; set; } - [Attr] public string Password { get; set; } + + [Attr] + public string Password + { + get => _password; + set + { + if (value != _password) + { + _password = value; + LastPasswordChange = DateTime.Now; + } + } + } + + [Attr] public DateTime LastPasswordChange { get; set; } } public sealed class SuperUser : User diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index e28b765960..e341150b04 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.RequestServices; namespace JsonApiDotNetCoreExample.Services { @@ -19,8 +20,9 @@ public CustomArticleService( ILoggerFactory loggerFactory, IResourceRepository repository, IResourceContextProvider provider, + IResourceChangeTracker
resourceChangeTracker, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, hookExecutor) + : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index 8cbe4eba4a..53a3301c39 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -21,6 +21,7 @@ using JsonApiDotNetCore.Serialization.Server; using Microsoft.Extensions.DependencyInjection.Extensions; using JsonApiDotNetCore.QueryParameterServices.Common; +using JsonApiDotNetCore.RequestServices; namespace JsonApiDotNetCore.Builders { @@ -161,6 +162,7 @@ public void ConfigureServices() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); _services.AddScoped(); AddServerSerialization(); diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 99fb055737..f9d5e4e712 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -133,7 +133,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions.IncludeExceptionStackTraceInErrors); var updatedEntity = await _update.UpdateAsync(id, entity); - return Ok(updatedEntity); + return updatedEntity == null ? Ok(null) : Ok(updatedEntity); } public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships) diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs index 0c3e2e948a..db23c4e7ed 100644 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs @@ -195,16 +195,12 @@ private void DetachRelationships(TResource entity) } /// - public virtual async Task UpdateAsync(TResource updatedEntity) + public virtual async Task UpdateAsync(TResource requestEntity, TResource databaseEntity) { - _logger.LogTrace($"Entering {nameof(UpdateAsync)}({(updatedEntity == null ? "null" : "object")})."); - - var databaseEntity = await Get(updatedEntity.Id).FirstOrDefaultAsync(); - if (databaseEntity == null) - return null; + _logger.LogTrace($"Entering {nameof(UpdateAsync)}({(requestEntity == null ? "null" : "object")}, {(databaseEntity == null ? "null" : "object")})."); foreach (var attribute in _targetedFields.Attributes) - attribute.SetValue(databaseEntity, attribute.GetValue(updatedEntity)); + attribute.SetValue(databaseEntity, attribute.GetValue(requestEntity)); foreach (var relationshipAttr in _targetedFields.Relationships) { @@ -213,7 +209,7 @@ public virtual async Task UpdateAsync(TResource updatedEntity) // 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, updatedEntity, out _); + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestEntity, out _); // loads into the db context any persons currently related // to the todoItems in trackedRelationshipValue LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); @@ -223,7 +219,6 @@ public virtual async Task UpdateAsync(TResource updatedEntity) } await _context.SaveChangesAsync(); - return databaseEntity; } /// @@ -303,6 +298,13 @@ public virtual async Task DeleteAsync(TId id) return true; } + public virtual void FlushFromCache(TResource entity) + { + _logger.LogTrace($"Entering {nameof(FlushFromCache)}({nameof(entity)})."); + + _context.Entry(entity).State = EntityState.Detached; + } + private IQueryable EagerLoad(IQueryable entities, IEnumerable attributes, string chainPrefix = null) { foreach (var attribute in attributes) diff --git a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs index ea70d70fa8..ad4c78ea60 100644 --- a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs @@ -14,10 +14,12 @@ public interface IResourceWriteRepository { Task CreateAsync(TResource entity); - Task UpdateAsync(TResource entity); + Task UpdateAsync(TResource requestEntity, TResource databaseEntity); Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); Task DeleteAsync(TId id); + + void FlushFromCache(TResource entity); } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index d16fd6075d..58e4ab5c6a 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using System.Net.Http; using JsonApiDotNetCore.Exceptions; diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/IUpdatedFields.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs similarity index 94% rename from src/JsonApiDotNetCore/RequestServices/Contracts/IUpdatedFields.cs rename to src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs index a40cf0d36f..556ea57094 100644 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/IUpdatedFields.cs +++ b/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Serialization diff --git a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs b/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs new file mode 100644 index 0000000000..d55f521edd --- /dev/null +++ b/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.RequestServices +{ + /// + /// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied. + /// + public interface IResourceChangeTracker where TResource : class, IIdentifiable + { + /// + /// Sets the exposed entity attributes as stored in database, before applying changes. + /// + void SetInitiallyStoredAttributeValues(TResource entity); + + /// + /// Sets the subset of exposed attributes from the PATCH request. + /// + void SetRequestedAttributeValues(TResource entity); + + /// + /// Sets the exposed entity attributes as stored in database, after applying changes. + /// + void SetFinallyStoredAttributeValues(TResource entity); + + /// + /// Validates if any exposed entity attributes that were not in the PATCH request have been changed. + /// And validates if the values from the PATCH request are stored without modification. + /// + /// + /// true if the attribute values from the PATCH request were the only changes; false, otherwise. + /// + bool HasImplicitChanges(); + } + + public sealed class DefaultResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable + { + private readonly IJsonApiOptions _options; + private readonly IResourceContextProvider _contextProvider; + private readonly ITargetedFields _targetedFields; + + private IDictionary _initiallyStoredAttributeValues; + private IDictionary _requestedAttributeValues; + private IDictionary _finallyStoredAttributeValues; + + public DefaultResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider, + ITargetedFields targetedFields) + { + _options = options; + _contextProvider = contextProvider; + _targetedFields = targetedFields; + } + + public void SetInitiallyStoredAttributeValues(TResource entity) + { + var resourceContext = _contextProvider.GetResourceContext(); + _initiallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes); + } + + public void SetRequestedAttributeValues(TResource entity) + { + _requestedAttributeValues = CreateAttributeDictionary(entity, _targetedFields.Attributes); + } + + public void SetFinallyStoredAttributeValues(TResource entity) + { + var resourceContext = _contextProvider.GetResourceContext(); + _finallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes); + } + + private IDictionary CreateAttributeDictionary(TResource resource, + IEnumerable attributes) + { + var result = new Dictionary(); + + foreach (var attribute in attributes) + { + object value = attribute.GetValue(resource); + // TODO: Remove explicit cast to JsonApiOptions after https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/687 has been fixed. + var json = JsonConvert.SerializeObject(value, ((JsonApiOptions) _options).SerializerSettings); + result.Add(attribute.PublicAttributeName, json); + } + + return result; + } + + public bool HasImplicitChanges() + { + foreach (var key in _initiallyStoredAttributeValues.Keys) + { + if (_requestedAttributeValues.ContainsKey(key)) + { + var requestedValue = _requestedAttributeValues[key]; + var actualValue = _finallyStoredAttributeValues[key]; + + if (requestedValue != actualValue) + { + return true; + } + } + else + { + var initiallyStoredValue = _initiallyStoredAttributeValues[key]; + var finallyStoredValue = _finallyStoredAttributeValues[key]; + + if (initiallyStoredValue != finallyStoredValue) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs index 1906469b03..2dcc449482 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs @@ -29,7 +29,7 @@ public string Serialize(IIdentifiable entity) { if (entity == null) { - var empty = Build((IIdentifiable) null, new List(), new List()); + var empty = Build((IIdentifiable) null, Array.Empty(), Array.Empty()); return SerializeObject(empty, _jsonSerializerSettings); } @@ -52,7 +52,7 @@ public string Serialize(IEnumerable entities) if (entity == null) { - var result = Build(entities, new List(), new List()); + var result = Build(entities, Array.Empty(), Array.Empty()); return SerializeObject(result, _jsonSerializerSettings); } diff --git a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs index 57f1c74ca9..b8808b0095 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs @@ -37,7 +37,7 @@ public DeserializedListResponse DeserializeList(string bod { Links = _document.Links, Meta = _document.Meta, - Data = ((List) entities)?.Cast().ToList(), + Data = ((ICollection) entities)?.Cast().ToList(), JsonApi = null, Errors = null }; diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index fbabd46d37..1454c0ef2b 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -51,7 +51,7 @@ protected object Deserialize(string body) if (_document.IsManyData) { if (_document.ManyData.Count == 0) - return new List(); + return Array.Empty(); return _document.ManyData.Select(ParseResourceObject).ToList(); } diff --git a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs index 11273f99ea..a154a6ae23 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs @@ -28,7 +28,7 @@ protected BaseDocumentBuilder(IResourceObjectBuilder resourceObjectBuilder) /// Attributes to include in the building process /// Relationships to include in the building process /// The resource object that was built - protected Document Build(IIdentifiable entity, List attributes, List relationships) + protected Document Build(IIdentifiable entity, IReadOnlyCollection attributes, IReadOnlyCollection relationships) { if (entity == null) return new Document(); @@ -44,7 +44,7 @@ protected Document Build(IIdentifiable entity, List attributes, L /// Attributes to include in the building process /// Relationships to include in the building process /// The resource object that was built - protected Document Build(IEnumerable entities, List attributes, List relationships) + protected Document Build(IEnumerable entities, IReadOnlyCollection attributes, IReadOnlyCollection relationships) { var data = new List(); foreach (IIdentifiable entity in entities) diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index 7466b4c461..e277f21d65 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -12,6 +12,8 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.RequestServices; +using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Services { @@ -29,6 +31,7 @@ public class DefaultResourceService : private readonly IFilterService _filterService; private readonly ISortService _sortService; private readonly IResourceRepository _repository; + private readonly IResourceChangeTracker _resourceChangeTracker; private readonly ILogger _logger; private readonly IResourceHookExecutor _hookExecutor; private readonly IIncludeService _includeService; @@ -41,6 +44,7 @@ public DefaultResourceService( ILoggerFactory loggerFactory, IResourceRepository repository, IResourceContextProvider provider, + IResourceChangeTracker resourceChangeTracker, IResourceHookExecutor hookExecutor = null) { _includeService = queryParameters.FirstOrDefault(); @@ -51,6 +55,7 @@ public DefaultResourceService( _options = options; _logger = loggerFactory.CreateLogger>(); _repository = repository; + _resourceChangeTracker = resourceChangeTracker; _hookExecutor = hookExecutor; _currentRequestResource = provider.GetResourceContext(); } @@ -183,25 +188,36 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh return relationship.GetValue(resource); } - public virtual async Task UpdateAsync(TId id, TResource entity) + public virtual async Task UpdateAsync(TId id, TResource requestEntity) { - _logger.LogTrace($"Entering {nameof(UpdateAsync)}('{id}', {(entity == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(UpdateAsync)}('{id}', {(requestEntity == null ? "null" : "object")})."); - entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.Patch).SingleOrDefault(); - entity = await _repository.UpdateAsync(entity); - - if (entity == null) + TResource databaseEntity = await _repository.Get(id).FirstOrDefaultAsync(); + if (databaseEntity == null) { string resourceId = TypeExtensions.GetResourceStringId(id); throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } - if (!IsNull(_hookExecutor, entity)) + _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseEntity); + _resourceChangeTracker.SetRequestedAttributeValues(requestEntity); + + requestEntity = IsNull(_hookExecutor) ? requestEntity : _hookExecutor.BeforeUpdate(AsList(requestEntity), ResourcePipeline.Patch).Single(); + + await _repository.UpdateAsync(requestEntity, databaseEntity); + + if (!IsNull(_hookExecutor, databaseEntity)) { - _hookExecutor.AfterUpdate(AsList(entity), ResourcePipeline.Patch); - entity = _hookExecutor.OnReturn(AsList(entity), ResourcePipeline.Patch).SingleOrDefault(); + _hookExecutor.AfterUpdate(AsList(databaseEntity), ResourcePipeline.Patch); + _hookExecutor.OnReturn(AsList(databaseEntity), ResourcePipeline.Patch); } - return entity; + + _repository.FlushFromCache(databaseEntity); + TResource afterEntity = await _repository.Get(databaseEntity.Id).FirstOrDefaultAsync(); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterEntity); + + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? afterEntity : null; } // triggered by PATCH /articles/1/relationships/{relationshipName} @@ -229,7 +245,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa : ((IEnumerable) related).Select(e => e.StringId).ToArray(); } - await _repository.UpdateRelationshipsAsync(entity, relationship, relationshipIds ?? new string[0] ); + await _repository.UpdateRelationshipsAsync(entity, relationship, relationshipIds ?? Array.Empty()); if (!IsNull(_hookExecutor, entity)) _hookExecutor.AfterUpdate(AsList(entity), ResourcePipeline.PatchRelationship); } @@ -397,8 +413,9 @@ public DefaultResourceService( ILoggerFactory loggerFactory, IResourceRepository repository, IResourceContextProvider provider, + IResourceChangeTracker resourceChangeTracker, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, hookExecutor) + : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor) { } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 244cc76e60..bfaa340554 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -11,6 +11,7 @@ using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Services; @@ -43,6 +44,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped((_) => new Mock().Object); _services.AddScoped((_) => new Mock().Object); _services.AddScoped((_) => new Mock().Object); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); _resourceGraphBuilder = new ResourceGraphBuilder(options); } @@ -111,8 +113,9 @@ public TestModelService( ILoggerFactory loggerFactory, IResourceRepository repository, IResourceContextProvider provider, + IResourceChangeTracker resourceChangeTracker, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, hookExecutor) + : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor) { } } diff --git a/test/IntegrationTests/Data/EntityRepositoryTests.cs b/test/IntegrationTests/Data/EntityRepositoryTests.cs index d4955ac42f..4ef139c18d 100644 --- a/test/IntegrationTests/Data/EntityRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityRepositoryTests.cs @@ -18,21 +18,29 @@ namespace JADNC.IntegrationTests.Data { public sealed class EntityRepositoryTests { - [Fact] - public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttributesUpdated() + [Fact] + public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttributesUpdated() { // Arrange var itemId = 213; var seed = Guid.NewGuid(); + + var databaseEntity = new TodoItem + { + Id = itemId, + Description = "Before" + }; + + var todoItemUpdates = new TodoItem + { + Id = itemId, + Description = "After" + }; + await using (var arrangeContext = GetContext(seed)) { var (repository, targetedFields) = Setup(arrangeContext); - var todoItemUpdates = new TodoItem - { - Id = itemId, - Description = Guid.NewGuid().ToString() - }; - arrangeContext.Add(todoItemUpdates); + arrangeContext.Add(databaseEntity); arrangeContext.SaveChanges(); var descAttr = new AttrAttribute("description") @@ -43,7 +51,7 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri targetedFields.Setup(m => m.Relationships).Returns(new List()); // Act - await repository.UpdateAsync(todoItemUpdates); + await repository.UpdateAsync(todoItemUpdates, databaseEntity); } // Assert - in different context @@ -53,36 +61,36 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri var fetchedTodo = repository.Get(itemId).First(); Assert.NotNull(fetchedTodo); - Assert.Equal(fetchedTodo.Ordinal, fetchedTodo.Ordinal); - Assert.Equal(fetchedTodo.Description, fetchedTodo.Description); + Assert.Equal(databaseEntity.Ordinal, fetchedTodo.Ordinal); + Assert.Equal(todoItemUpdates.Description, fetchedTodo.Description); } } - [Theory] - [InlineData(3, 2, new[] { 4, 5, 6 })] - [InlineData(8, 2, new[] { 9 })] - [InlineData(20, 1, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 })] - public async Task Paging_PageNumberIsPositive_ReturnCorrectIdsAtTheFront(int pageSize, int pageNumber, int[] expectedResult) + [Theory] + [InlineData(3, 2, new[] { 4, 5, 6 })] + [InlineData(8, 2, new[] { 9 })] + [InlineData(20, 1, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 })] + public async Task Paging_PageNumberIsPositive_ReturnCorrectIdsAtTheFront(int pageSize, int pageNumber, int[] expectedResult) { // Arrange await using var context = GetContext(); var (repository, _) = Setup(context); context.AddRange(TodoItems(1, 2, 3, 4, 5, 6, 7, 8, 9).Cast()); - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); - // Act - var result = await repository.PageAsync(context.Set(), pageSize, pageNumber); + // Act + var result = await repository.PageAsync(context.Set(), pageSize, pageNumber); - // Assert - Assert.Equal(TodoItems(expectedResult), result, new IdComparer()); + // Assert + Assert.Equal(TodoItems(expectedResult), result, new IdComparer()); } - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-10)] - public async Task Paging_PageSizeNonPositive_DoNothing(int pageSize) + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public async Task Paging_PageSizeNonPositive_DoNothing(int pageSize) { // Arrange await using var context = GetContext(); @@ -91,43 +99,43 @@ public async Task Paging_PageSizeNonPositive_DoNothing(int pageSize) context.AddRange(items.Cast()); await context.SaveChangesAsync(); - // Act - var result = await repository.PageAsync(context.Set(), pageSize, 3); + // Act + var result = await repository.PageAsync(context.Set(), pageSize, 3); - // Assert - Assert.Equal(items.ToList(), result.ToList(), new IdComparer()); + // Assert + Assert.Equal(items.ToList(), result.ToList(), new IdComparer()); } - [Fact] - public async Task Paging_PageNumberDoesNotExist_ReturnEmptyAQueryable() + [Fact] + public async Task Paging_PageNumberDoesNotExist_ReturnEmptyAQueryable() { // Arrange var items = TodoItems(2, 3, 1); await using var context = GetContext(); var (repository, _) = Setup(context); - context.AddRange(items.Cast()); + context.AddRange(items.Cast()); - // Act - var result = await repository.PageAsync(context.Set(), 2, 3); + // Act + var result = await repository.PageAsync(context.Set(), 2, 3); - // Assert - Assert.Empty(result); + // Assert + Assert.Empty(result); } - [Fact] - public async Task Paging_PageNumberIsZero_PretendsItsOne() + [Fact] + public async Task Paging_PageNumberIsZero_PretendsItsOne() { // Arrange await using var context = GetContext(); var (repository, _) = Setup(context); context.AddRange(TodoItems(2, 3, 4, 5, 6, 7, 8, 9).Cast()); - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); - // Act - var result = await repository.PageAsync(entities: context.Set(), pageSize: 1, pageNumber: 0); + // Act + var result = await repository.PageAsync(entities: context.Set(), pageSize: 1, pageNumber: 0); - // Assert - Assert.Equal(TodoItems(2), result, new IdComparer()); + // Assert + Assert.Equal(TodoItems(2), result, new IdComparer()); } [Theory] @@ -172,17 +180,17 @@ private AppDbContext GetContext(Guid? seed = null) return context; } - private static TodoItem[] TodoItems(params int[] ids) - { - return ids.Select(id => new TodoItem { Id = id }).ToArray(); - } - - private sealed class IdComparer : IEqualityComparer - where T : IIdentifiable - { - public bool Equals(T x, T y) => x?.StringId == y?.StringId; - - public int GetHashCode(T obj) => obj.StringId?.GetHashCode() ?? 0; + private static TodoItem[] TodoItems(params int[] ids) + { + return ids.Select(id => new TodoItem { Id = id }).ToArray(); + } + + private sealed class IdComparer : IEqualityComparer + where T : IIdentifiable + { + public bool Equals(T x, T y) => x?.StringId == y?.StringId; + + public int GetHashCode(T obj) => obj.StringId?.GetHashCode() ?? 0; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index 792de4bf58..e2e18fef53 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; @@ -84,7 +85,10 @@ public async Task KebabCaseFormatter_Update_IsUpdated() // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(model.CompoundAttr, responseItem.CompoundAttr); + Assert.Null(responseItem); + + var stored = _dbContext.KebabCasedModels.Single(x => x.Id == model.Id); + Assert.Equal(model.CompoundAttr, stored.CompoundAttr); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 0a2a86afd5..b4e194a962 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -385,12 +385,12 @@ public async Task Can_Update_Many_To_Many() Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); + Assert.Null(articleResponse); _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles .Include(a => a.ArticleTags) - .SingleAsync(a => a.Id == articleResponse.Id); + .SingleAsync(a => a.Id == article.Id); var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); Assert.Equal(tag.Id, persistedArticleTag.TagId); @@ -450,7 +450,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); + Assert.Null(articleResponse); _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles @@ -518,7 +518,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); + Assert.Null(articleResponse); _fixture.ReloadDbContext(); var persistedArticle = await _fixture.Context.Articles diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs index 1a8eae7521..66f596a6e3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs @@ -48,9 +48,9 @@ public async Task When_getting_person_it_must_match_JSON_text() var bodyText = await response.Content.ReadAsStringAsync(); var token = JsonConvert.DeserializeObject(bodyText); - var bodyFormatted = token.ToString().Replace("\r\n", "\n"); - - Assert.Equal(@"{ + var bodyFormatted = NormalizeLineEndings(token.ToString()); + + var expectedText = NormalizeLineEndings(@"{ ""meta"": { ""copyright"": ""Copyright 2015 Example Corp."", ""authors"": [ @@ -67,6 +67,7 @@ public async Task When_getting_person_it_must_match_JSON_text() ""id"": ""123"", ""attributes"": { ""firstName"": ""John"", + ""initials"": ""J"", ""lastName"": ""Doe"", ""the-Age"": 57, ""gender"": ""Male"" @@ -125,7 +126,13 @@ public async Task When_getting_person_it_must_match_JSON_text() ""self"": ""http://localhost/api/v1/people/123"" } } -}", bodyFormatted); +}"); + Assert.Equal(expectedText, bodyFormatted); + } + + private static string NormalizeLineEndings(string text) + { + return text.Replace("\r\n", "\n").Replace("\r", "\n"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 661fc9c541..6cb572ae29 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -46,19 +46,22 @@ public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() { // Arrange var dbContext = PrepareTest(); - var serializer = GetSerializer(e => new { e.SecurityLevel }); - var superUser = new SuperUser { SecurityLevel = 1337, Username = "Super", Password = "User" }; + var serializer = GetSerializer(e => new { e.SecurityLevel, e.Username, e.Password }); + var superUser = new SuperUser { SecurityLevel = 1337, Username = "Super", Password = "User", LastPasswordChange = DateTime.Now.AddMinutes(-15) }; dbContext.SuperUsers.Add(superUser); dbContext.SaveChanges(); - var su = new SuperUser { Id = superUser.Id, SecurityLevel = 2674 }; + var su = new SuperUser { Id = superUser.Id, SecurityLevel = 2674, Username = "Power", Password = "secret" }; + var content = serializer.Serialize(su); // Act - var (body, response) = await Patch($"/api/v1/superUsers/{su.Id}", serializer.Serialize(su)); + var (body, response) = await Patch($"/api/v1/superUsers/{su.Id}", content); // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); var updated = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(2674, updated.SecurityLevel); + Assert.Equal(su.SecurityLevel, updated.SecurityLevel); + Assert.Equal(su.Username, updated.Username); + Assert.Equal(su.Password, updated.Password); } [Fact] @@ -253,7 +256,7 @@ public async Task Can_Patch_Entity() } [Fact] - public async Task Patch_Entity_With_HasMany_Does_Not_Included_Relationships() + public async Task Patch_Entity_With_HasMany_Does_Not_Include_Relationships() { // Arrange var todoItem = _todoItemFaker.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 7a5ee9b249..e147b619b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -641,6 +641,7 @@ public async Task Can_Patch_TodoItem() { { "description", newTodoItem.Description }, { "ordinal", newTodoItem.Ordinal }, + { "alwaysChangingValue", "ignored" }, { "createdDate", newTodoItem.CreatedDate } } } diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index 5d2d53f116..d181b994e3 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -1,8 +1,5 @@ -using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; -using Humanizer; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions.EntityFrameworkCore; diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index b33663da31..e4749fec8f 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Net; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 8bfc24e0fa..95e28c4a8d 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; @@ -54,7 +54,7 @@ protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(List
  • attributes = null, List relationships = null) + public new Document Build(IIdentifiable entity, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) { return base.Build(entity, attributes, relationships); } - public new Document Build(IEnumerable entities, List attributes = null, List relationships = null) + public new Document Build(IEnumerable entities, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) { return base.Build(entities, attributes, relationships); } } } -} +} \ No newline at end of file diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs index 5d46805d18..df2e85feee 100644 --- a/test/UnitTests/Services/EntityResourceService_Tests.cs +++ b/test/UnitTests/Services/EntityResourceService_Tests.cs @@ -8,6 +8,8 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging.Abstractions; @@ -100,10 +102,10 @@ public async Task GetRelationshipAsync_Returns_Relationship_Value() _repositoryMock.Setup(m => m.Include(query, relationships)).Returns(query); _repositoryMock.Setup(m => m.FirstOrDefaultAsync(query)).ReturnsAsync(todoItem); - var repository = GetService(); + var service = GetService(); // Act - var result = await repository.GetRelationshipAsync(id, relationshipName); + var result = await service.GetRelationshipAsync(id, relationshipName); // Assert Assert.NotNull(result); @@ -119,7 +121,10 @@ private DefaultResourceService GetService() _sortService.Object, _sparseFieldsService.Object }; - return new DefaultResourceService(queryParamServices, new JsonApiOptions(), NullLoggerFactory.Instance, _repositoryMock.Object, _resourceGraph); + var options = new JsonApiOptions(); + var changeTracker = new DefaultResourceChangeTracker(options, _resourceGraph, new TargetedFields()); + + return new DefaultResourceService(queryParamServices, options, NullLoggerFactory.Instance, _repositoryMock.Object, _resourceGraph, changeTracker); } } }