Skip to content

Change tracking for patch updates, to improve json:api spec compliance #704

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
Expand All @@ -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; }
Expand Down
19 changes: 18 additions & 1 deletion src/Examples/JsonApiDotNetCoreExample/Models/Person.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using JsonApiDotNetCore.Models;
using JsonApiDotNetCore.Models.Links;

Expand All @@ -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; }
Expand Down
7 changes: 7 additions & 0 deletions src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
20 changes: 19 additions & 1 deletion src/Examples/JsonApiDotNetCoreExample/Models/User.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Threading.Tasks;
using JsonApiDotNetCore.RequestServices;

namespace JsonApiDotNetCoreExample.Services
{
Expand All @@ -19,8 +20,9 @@ public CustomArticleService(
ILoggerFactory loggerFactory,
IResourceRepository<Article, int> repository,
IResourceContextProvider provider,
IResourceChangeTracker<Article> resourceChangeTracker,
IResourceHookExecutor hookExecutor = null)
: base(queryParameters, options, loggerFactory, repository, provider, hookExecutor)
: base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor)
{ }

public override async Task<Article> GetAsync(int id)
Expand Down
2 changes: 2 additions & 0 deletions src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using JsonApiDotNetCore.Serialization.Server;
using Microsoft.Extensions.DependencyInjection.Extensions;
using JsonApiDotNetCore.QueryParameterServices.Common;
using JsonApiDotNetCore.RequestServices;

namespace JsonApiDotNetCore.Builders
{
Expand Down Expand Up @@ -161,6 +162,7 @@ public void ConfigureServices()
_services.AddScoped<ITargetedFields, TargetedFields>();
_services.AddScoped<IResourceDefinitionProvider, ResourceDefinitionProvider>();
_services.AddScoped<IFieldsToSerialize, FieldsToSerialize>();
_services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>));
_services.AddScoped<IQueryParameterActionFilter, QueryParameterActionFilter>();

AddServerSerialization();
Expand Down
2 changes: 1 addition & 1 deletion src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public virtual async Task<IActionResult> 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<IActionResult> PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships)
Expand Down
20 changes: 11 additions & 9 deletions src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,12 @@ private void DetachRelationships(TResource entity)
}

/// <inheritdoc />
public virtual async Task<TResource> 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)
{
Expand All @@ -213,7 +209,7 @@ public virtual async Task<TResource> 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);
Expand All @@ -223,7 +219,6 @@ public virtual async Task<TResource> UpdateAsync(TResource updatedEntity)
}

await _context.SaveChangesAsync();
return databaseEntity;
}

/// <summary>
Expand Down Expand Up @@ -303,6 +298,13 @@ public virtual async Task<bool> 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<TResource> EagerLoad(IQueryable<TResource> entities, IEnumerable<EagerLoadAttribute> attributes, string chainPrefix = null)
{
foreach (var attribute in attributes)
Expand Down
4 changes: 3 additions & 1 deletion src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ public interface IResourceWriteRepository<TResource, in TId>
{
Task<TResource> CreateAsync(TResource entity);

Task<TResource> UpdateAsync(TResource entity);
Task UpdateAsync(TResource requestEntity, TResource databaseEntity);

Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable<string> relationshipIds);

Task<bool> DeleteAsync(TId id);

void FlushFromCache(TResource entity);
}
}
1 change: 0 additions & 1 deletion src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System;
using System.Linq;
using System.Net.Http;
using JsonApiDotNetCore.Exceptions;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using JsonApiDotNetCore.Models;

namespace JsonApiDotNetCore.Serialization
Expand Down
120 changes: 120 additions & 0 deletions src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied.
/// </summary>
public interface IResourceChangeTracker<in TResource> where TResource : class, IIdentifiable
{
/// <summary>
/// Sets the exposed entity attributes as stored in database, before applying changes.
/// </summary>
void SetInitiallyStoredAttributeValues(TResource entity);

/// <summary>
/// Sets the subset of exposed attributes from the PATCH request.
/// </summary>
void SetRequestedAttributeValues(TResource entity);

/// <summary>
/// Sets the exposed entity attributes as stored in database, after applying changes.
/// </summary>
void SetFinallyStoredAttributeValues(TResource entity);

/// <summary>
/// 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.
/// </summary>
/// <returns>
/// <c>true</c> if the attribute values from the PATCH request were the only changes; <c>false</c>, otherwise.
/// </returns>
bool HasImplicitChanges();
}

public sealed class DefaultResourceChangeTracker<TResource> : IResourceChangeTracker<TResource> where TResource : class, IIdentifiable
{
private readonly IJsonApiOptions _options;
private readonly IResourceContextProvider _contextProvider;
private readonly ITargetedFields _targetedFields;

private IDictionary<string, string> _initiallyStoredAttributeValues;
private IDictionary<string, string> _requestedAttributeValues;
private IDictionary<string, string> _finallyStoredAttributeValues;

public DefaultResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider,
ITargetedFields targetedFields)
{
_options = options;
_contextProvider = contextProvider;
_targetedFields = targetedFields;
}

public void SetInitiallyStoredAttributeValues(TResource entity)
{
var resourceContext = _contextProvider.GetResourceContext<TResource>();
_initiallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes);
}

public void SetRequestedAttributeValues(TResource entity)
{
_requestedAttributeValues = CreateAttributeDictionary(entity, _targetedFields.Attributes);
}

public void SetFinallyStoredAttributeValues(TResource entity)
{
var resourceContext = _contextProvider.GetResourceContext<TResource>();
_finallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes);
}

private IDictionary<string, string> CreateAttributeDictionary(TResource resource,
IEnumerable<AttrAttribute> attributes)
{
var result = new Dictionary<string, string>();

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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public string Serialize(IIdentifiable entity)
{
if (entity == null)
{
var empty = Build((IIdentifiable) null, new List<AttrAttribute>(), new List<RelationshipAttribute>());
var empty = Build((IIdentifiable) null, Array.Empty<AttrAttribute>(), Array.Empty<RelationshipAttribute>());
return SerializeObject(empty, _jsonSerializerSettings);
}

Expand All @@ -52,7 +52,7 @@ public string Serialize(IEnumerable entities)

if (entity == null)
{
var result = Build(entities, new List<AttrAttribute>(), new List<RelationshipAttribute>());
var result = Build(entities, Array.Empty<AttrAttribute>(), Array.Empty<RelationshipAttribute>());
return SerializeObject(result, _jsonSerializerSettings);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public DeserializedListResponse<TResource> DeserializeList<TResource>(string bod
{
Links = _document.Links,
Meta = _document.Meta,
Data = ((List<IIdentifiable>) entities)?.Cast<TResource>().ToList(),
Data = ((ICollection<IIdentifiable>) entities)?.Cast<TResource>().ToList(),
JsonApi = null,
Errors = null
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ protected object Deserialize(string body)
if (_document.IsManyData)
{
if (_document.ManyData.Count == 0)
return new List<IIdentifiable>();
return Array.Empty<IIdentifiable>();

return _document.ManyData.Select(ParseResourceObject).ToList();
}
Expand Down
Loading