From 5919dcb94bd4679d53d985cc88021758c9984db7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Mar 2020 19:24:38 +0100 Subject: [PATCH 1/3] Change tracking for patch updates, to improve json:api spec compliance I have added some calculated properties to the examples, in order to have tests for: - nothing outside the patch request changed (returns empty data) - `KebabCaseFormatterTests.KebabCaseFormatter_Update_IsUpdated` has no side effects in updating `KebabCasedModel` attributes - `ManyToManyTests.Can_Update_Many_To_Many` has no side effects in updating `Article` tags - `ManyToManyTests.Can_Update_Many_To_Many_With_Complete_Replacement` has no side effects in updating `Article` tags - `ManyToManyTests.Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap` has no side effects in updating `Article` tags - exposed attribute that was not in PATCH request changed (returns attributes) - `UpdatingDataTests.PatchResource_ModelWithEntityFrameworkInheritance_IsPatched` updates `User.Password` property, which updates exposed `LastPasswordChange` attribute - `UpdatingDataTests.Patch_Entity_With_HasMany_Does_Not_Include_Relationships` updates `Person.FirstName` property, which updates exposed `Initials` attribute - `TodoItemsControllerTests.Can_Patch_TodoItemWithNullable` does not update exposed `TodoItem.AlwaysChangingValue` attribute - exposed attribute that was in PATCH request changed (returns attributes) - `TodoItemsControllerTests.Can_Patch_TodoItem` updates `TodoItem.AlwaysChangingValue` attribute Also updated a few places where an empty list was allocated each time with a cached empty array instance. --- .../Models/Passport.cs | 19 ++- .../JsonApiDotNetCoreExample/Models/Person.cs | 19 ++- .../Models/TodoItem.cs | 7 + .../JsonApiDotNetCoreExample/Models/User.cs | 20 ++- .../Services/CustomArticleService.cs | 4 +- .../Builders/JsonApiApplicationBuilder.cs | 2 + .../Controllers/BaseJsonApiController.cs | 2 +- .../Data/DefaultResourceRepository.cs | 20 +-- .../Data/IResourceWriteRepository.cs | 4 +- .../Middleware/DefaultTypeMatchFilter.cs | 1 - .../{IUpdatedFields.cs => ITargetedFields.cs} | 2 +- .../DefaultResourceChangeTracker.cs | 120 ++++++++++++++++++ .../Serialization/Client/RequestSerializer.cs | 6 +- .../Client/ResponseDeserializer.cs | 2 +- .../Common/BaseDocumentParser.cs | 2 +- .../Serialization/Common/DocumentBuilder.cs | 4 +- .../Services/DefaultResourceService.cs | 41 ++++-- .../ServiceDiscoveryFacadeTests.cs | 5 +- .../Data/EntityRepositoryTests.cs | 118 +++++++++-------- .../Acceptance/KebabCaseFormatterTests.cs | 6 +- .../Acceptance/ManyToManyTests.cs | 8 +- .../Acceptance/Spec/UpdatingDataTests.cs | 15 ++- .../Acceptance/TodoItemsControllerTests.cs | 1 + .../Builders/ContextGraphBuilder_Tests.cs | 3 - .../SparseFieldsServiceTests.cs | 1 - .../Serialization/SerializerTestsSetup.cs | 10 +- .../Services/EntityResourceService_Tests.cs | 11 +- 27 files changed, 338 insertions(+), 115 deletions(-) rename src/JsonApiDotNetCore/RequestServices/Contracts/{IUpdatedFields.cs => ITargetedFields.cs} (94%) create mode 100644 src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs 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 58e2c8c67b..5b1f448534 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 8ff48140cc..d4b72b920b 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 b90c9a4991..277de7dd23 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); 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 94fd06cb39..5e06e32f82 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -27,7 +27,7 @@ public RequestSerializer(IResourceGraph resourceGraph, public string Serialize(IIdentifiable entity) { if (entity == null) - return JsonConvert.SerializeObject(Build((IIdentifiable) null, new List(), new List())); + return JsonConvert.SerializeObject(Build((IIdentifiable) null, Array.Empty(), Array.Empty())); _currentTargetedResource = entity.GetType(); var document = Build(entity, GetAttributesToSerialize(entity), GetRelationshipsToSerialize(entity)); @@ -45,7 +45,7 @@ public string Serialize(IEnumerable entities) break; } if (entity == null) - return JsonConvert.SerializeObject(Build(entities, new List(), new List())); + return JsonConvert.SerializeObject(Build(entities, Array.Empty(), Array.Empty())); _currentTargetedResource = entity.GetType(); var attributes = GetAttributesToSerialize(entity); 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 9f31e18e2c..7d63ced867 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs @@ -25,7 +25,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(); @@ -41,7 +41,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 9a62c99457..7205adc9a6 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; @@ -40,6 +41,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped((_) => new Mock().Object); _services.AddScoped((_) => new Mock().Object); _services.AddScoped((_) => new Mock().Object); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); } private ServiceDiscoveryFacade Facade => new ServiceDiscoveryFacade(_services, _resourceGraphBuilder); @@ -106,8 +108,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 033cd3a399..f5bc631523 100644 --- a/test/IntegrationTests/Data/EntityRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityRepositoryTests.cs @@ -17,21 +17,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") @@ -42,7 +50,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 @@ -52,36 +60,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(); @@ -90,43 +98,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] @@ -171,17 +179,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/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 2825371b7a..8b224d92d1 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 fc66d36f2f..ce39543b58 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.Extensions.EntityFrameworkCore; using JsonApiDotNetCore.Graph; 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 5796d3fc4c..078d4e42a2 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.Graph; @@ -53,7 +53,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 7f0cedffd6..929f2973f5 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; @@ -101,10 +103,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); @@ -120,7 +122,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); } } } From fdc9589edbe4fdb638ecf369e4f2e2065698e55c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 13 Apr 2020 14:48:02 +0200 Subject: [PATCH 2/3] Fixed: ignore line endings --- .../Acceptance/SerializationTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs index 1a8eae7521..83f87ce7d9 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"": [ @@ -125,7 +125,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"); } } } From d3d4ebe7588a31d46db1f46b4fc3b18bf9651098 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 13 Apr 2020 14:50:11 +0200 Subject: [PATCH 3/3] Post-merge fixes --- .../Acceptance/SerializationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs index 83f87ce7d9..66f596a6e3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs @@ -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""