Skip to content

Commit 74f32f7

Browse files
author
Bart Koelman
authored
Change tracking for patch updates, to improve json:api spec compliance (#704)
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.
1 parent c13d950 commit 74f32f7

28 files changed

+348
-118
lines changed

src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.ComponentModel.DataAnnotations.Schema;
34
using System.Linq;
@@ -7,8 +8,24 @@ namespace JsonApiDotNetCoreExample.Models
78
{
89
public class Passport : Identifiable
910
{
11+
private int? _socialSecurityNumber;
12+
13+
[Attr]
14+
public int? SocialSecurityNumber
15+
{
16+
get => _socialSecurityNumber;
17+
set
18+
{
19+
if (value != _socialSecurityNumber)
20+
{
21+
LastSocialSecurityNumberChange = DateTime.Now;
22+
_socialSecurityNumber = value;
23+
}
24+
}
25+
}
26+
1027
[Attr]
11-
public int? SocialSecurityNumber { get; set; }
28+
public DateTime LastSocialSecurityNumberChange { get; set; }
1229

1330
[Attr]
1431
public bool IsLocked { get; set; }

src/Examples/JsonApiDotNetCoreExample/Models/Person.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Generic;
2+
using System.Linq;
23
using JsonApiDotNetCore.Models;
34
using JsonApiDotNetCore.Models.Links;
45

@@ -12,10 +13,26 @@ public sealed class PersonRole : Identifiable
1213

1314
public sealed class Person : Identifiable, IIsLockable
1415
{
16+
private string _firstName;
17+
1518
public bool IsLocked { get; set; }
1619

1720
[Attr]
18-
public string FirstName { get; set; }
21+
public string FirstName
22+
{
23+
get => _firstName;
24+
set
25+
{
26+
if (value != _firstName)
27+
{
28+
_firstName = value;
29+
Initials = string.Concat(value.Split(' ').Select(x => char.ToUpperInvariant(x[0])));
30+
}
31+
}
32+
}
33+
34+
[Attr]
35+
public string Initials { get; set; }
1936

2037
[Attr]
2138
public string LastName { get; set; }

src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ public TodoItem()
2222
[Attr]
2323
public Guid GuidProperty { get; set; }
2424

25+
[Attr]
26+
public string AlwaysChangingValue
27+
{
28+
get => Guid.NewGuid().ToString();
29+
set { }
30+
}
31+
2532
[Attr]
2633
public DateTime CreatedDate { get; set; }
2734

src/Examples/JsonApiDotNetCoreExample/Models/User.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
1+
using System;
12
using JsonApiDotNetCore.Models;
23

34
namespace JsonApiDotNetCoreExample.Models
45
{
56
public class User : Identifiable
67
{
8+
private string _password;
9+
710
[Attr] public string Username { get; set; }
8-
[Attr] public string Password { get; set; }
11+
12+
[Attr]
13+
public string Password
14+
{
15+
get => _password;
16+
set
17+
{
18+
if (value != _password)
19+
{
20+
_password = value;
21+
LastPasswordChange = DateTime.Now;
22+
}
23+
}
24+
}
25+
26+
[Attr] public DateTime LastPasswordChange { get; set; }
927
}
1028

1129
public sealed class SuperUser : User

src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.Extensions.Logging;
99
using System.Collections.Generic;
1010
using System.Threading.Tasks;
11+
using JsonApiDotNetCore.RequestServices;
1112

1213
namespace JsonApiDotNetCoreExample.Services
1314
{
@@ -19,8 +20,9 @@ public CustomArticleService(
1920
ILoggerFactory loggerFactory,
2021
IResourceRepository<Article, int> repository,
2122
IResourceContextProvider provider,
23+
IResourceChangeTracker<Article> resourceChangeTracker,
2224
IResourceHookExecutor hookExecutor = null)
23-
: base(queryParameters, options, loggerFactory, repository, provider, hookExecutor)
25+
: base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor)
2426
{ }
2527

2628
public override async Task<Article> GetAsync(int id)

src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
using JsonApiDotNetCore.Serialization.Server;
2222
using Microsoft.Extensions.DependencyInjection.Extensions;
2323
using JsonApiDotNetCore.QueryParameterServices.Common;
24+
using JsonApiDotNetCore.RequestServices;
2425

2526
namespace JsonApiDotNetCore.Builders
2627
{
@@ -161,6 +162,7 @@ public void ConfigureServices()
161162
_services.AddScoped<ITargetedFields, TargetedFields>();
162163
_services.AddScoped<IResourceDefinitionProvider, ResourceDefinitionProvider>();
163164
_services.AddScoped<IFieldsToSerialize, FieldsToSerialize>();
165+
_services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>));
164166
_services.AddScoped<IQueryParameterActionFilter, QueryParameterActionFilter>();
165167

166168
AddServerSerialization();

src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] T entity)
133133
throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions.IncludeExceptionStackTraceInErrors);
134134

135135
var updatedEntity = await _update.UpdateAsync(id, entity);
136-
return Ok(updatedEntity);
136+
return updatedEntity == null ? Ok(null) : Ok(updatedEntity);
137137
}
138138

139139
public virtual async Task<IActionResult> PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships)

src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,16 +195,12 @@ private void DetachRelationships(TResource entity)
195195
}
196196

197197
/// <inheritdoc />
198-
public virtual async Task<TResource> UpdateAsync(TResource updatedEntity)
198+
public virtual async Task UpdateAsync(TResource requestEntity, TResource databaseEntity)
199199
{
200-
_logger.LogTrace($"Entering {nameof(UpdateAsync)}({(updatedEntity == null ? "null" : "object")}).");
201-
202-
var databaseEntity = await Get(updatedEntity.Id).FirstOrDefaultAsync();
203-
if (databaseEntity == null)
204-
return null;
200+
_logger.LogTrace($"Entering {nameof(UpdateAsync)}({(requestEntity == null ? "null" : "object")}, {(databaseEntity == null ? "null" : "object")}).");
205201

206202
foreach (var attribute in _targetedFields.Attributes)
207-
attribute.SetValue(databaseEntity, attribute.GetValue(updatedEntity));
203+
attribute.SetValue(databaseEntity, attribute.GetValue(requestEntity));
208204

209205
foreach (var relationshipAttr in _targetedFields.Relationships)
210206
{
@@ -213,7 +209,7 @@ public virtual async Task<TResource> UpdateAsync(TResource updatedEntity)
213209
// trackedRelationshipValue is either equal to updatedPerson.todoItems,
214210
// or replaced with the same set (same ids) of todoItems from the EF Core change tracker,
215211
// which is the case if they were already tracked
216-
object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, updatedEntity, out _);
212+
object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestEntity, out _);
217213
// loads into the db context any persons currently related
218214
// to the todoItems in trackedRelationshipValue
219215
LoadInverseRelationships(trackedRelationshipValue, relationshipAttr);
@@ -223,7 +219,6 @@ public virtual async Task<TResource> UpdateAsync(TResource updatedEntity)
223219
}
224220

225221
await _context.SaveChangesAsync();
226-
return databaseEntity;
227222
}
228223

229224
/// <summary>
@@ -303,6 +298,13 @@ public virtual async Task<bool> DeleteAsync(TId id)
303298
return true;
304299
}
305300

301+
public virtual void FlushFromCache(TResource entity)
302+
{
303+
_logger.LogTrace($"Entering {nameof(FlushFromCache)}({nameof(entity)}).");
304+
305+
_context.Entry(entity).State = EntityState.Detached;
306+
}
307+
306308
private IQueryable<TResource> EagerLoad(IQueryable<TResource> entities, IEnumerable<EagerLoadAttribute> attributes, string chainPrefix = null)
307309
{
308310
foreach (var attribute in attributes)

src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ public interface IResourceWriteRepository<TResource, in TId>
1414
{
1515
Task<TResource> CreateAsync(TResource entity);
1616

17-
Task<TResource> UpdateAsync(TResource entity);
17+
Task UpdateAsync(TResource requestEntity, TResource databaseEntity);
1818

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

2121
Task<bool> DeleteAsync(TId id);
22+
23+
void FlushFromCache(TResource entity);
2224
}
2325
}

src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System;
21
using System.Linq;
32
using System.Net.Http;
43
using JsonApiDotNetCore.Exceptions;

src/JsonApiDotNetCore/RequestServices/Contracts/IUpdatedFields.cs renamed to src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22
using JsonApiDotNetCore.Models;
33

44
namespace JsonApiDotNetCore.Serialization
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using System.Collections.Generic;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Internal.Contracts;
4+
using JsonApiDotNetCore.Models;
5+
using JsonApiDotNetCore.Serialization;
6+
using Newtonsoft.Json;
7+
8+
namespace JsonApiDotNetCore.RequestServices
9+
{
10+
/// <summary>
11+
/// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied.
12+
/// </summary>
13+
public interface IResourceChangeTracker<in TResource> where TResource : class, IIdentifiable
14+
{
15+
/// <summary>
16+
/// Sets the exposed entity attributes as stored in database, before applying changes.
17+
/// </summary>
18+
void SetInitiallyStoredAttributeValues(TResource entity);
19+
20+
/// <summary>
21+
/// Sets the subset of exposed attributes from the PATCH request.
22+
/// </summary>
23+
void SetRequestedAttributeValues(TResource entity);
24+
25+
/// <summary>
26+
/// Sets the exposed entity attributes as stored in database, after applying changes.
27+
/// </summary>
28+
void SetFinallyStoredAttributeValues(TResource entity);
29+
30+
/// <summary>
31+
/// Validates if any exposed entity attributes that were not in the PATCH request have been changed.
32+
/// And validates if the values from the PATCH request are stored without modification.
33+
/// </summary>
34+
/// <returns>
35+
/// <c>true</c> if the attribute values from the PATCH request were the only changes; <c>false</c>, otherwise.
36+
/// </returns>
37+
bool HasImplicitChanges();
38+
}
39+
40+
public sealed class DefaultResourceChangeTracker<TResource> : IResourceChangeTracker<TResource> where TResource : class, IIdentifiable
41+
{
42+
private readonly IJsonApiOptions _options;
43+
private readonly IResourceContextProvider _contextProvider;
44+
private readonly ITargetedFields _targetedFields;
45+
46+
private IDictionary<string, string> _initiallyStoredAttributeValues;
47+
private IDictionary<string, string> _requestedAttributeValues;
48+
private IDictionary<string, string> _finallyStoredAttributeValues;
49+
50+
public DefaultResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider,
51+
ITargetedFields targetedFields)
52+
{
53+
_options = options;
54+
_contextProvider = contextProvider;
55+
_targetedFields = targetedFields;
56+
}
57+
58+
public void SetInitiallyStoredAttributeValues(TResource entity)
59+
{
60+
var resourceContext = _contextProvider.GetResourceContext<TResource>();
61+
_initiallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes);
62+
}
63+
64+
public void SetRequestedAttributeValues(TResource entity)
65+
{
66+
_requestedAttributeValues = CreateAttributeDictionary(entity, _targetedFields.Attributes);
67+
}
68+
69+
public void SetFinallyStoredAttributeValues(TResource entity)
70+
{
71+
var resourceContext = _contextProvider.GetResourceContext<TResource>();
72+
_finallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes);
73+
}
74+
75+
private IDictionary<string, string> CreateAttributeDictionary(TResource resource,
76+
IEnumerable<AttrAttribute> attributes)
77+
{
78+
var result = new Dictionary<string, string>();
79+
80+
foreach (var attribute in attributes)
81+
{
82+
object value = attribute.GetValue(resource);
83+
// TODO: Remove explicit cast to JsonApiOptions after https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/687 has been fixed.
84+
var json = JsonConvert.SerializeObject(value, ((JsonApiOptions) _options).SerializerSettings);
85+
result.Add(attribute.PublicAttributeName, json);
86+
}
87+
88+
return result;
89+
}
90+
91+
public bool HasImplicitChanges()
92+
{
93+
foreach (var key in _initiallyStoredAttributeValues.Keys)
94+
{
95+
if (_requestedAttributeValues.ContainsKey(key))
96+
{
97+
var requestedValue = _requestedAttributeValues[key];
98+
var actualValue = _finallyStoredAttributeValues[key];
99+
100+
if (requestedValue != actualValue)
101+
{
102+
return true;
103+
}
104+
}
105+
else
106+
{
107+
var initiallyStoredValue = _initiallyStoredAttributeValues[key];
108+
var finallyStoredValue = _finallyStoredAttributeValues[key];
109+
110+
if (initiallyStoredValue != finallyStoredValue)
111+
{
112+
return true;
113+
}
114+
}
115+
}
116+
117+
return false;
118+
}
119+
}
120+
}

src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public string Serialize(IIdentifiable entity)
2929
{
3030
if (entity == null)
3131
{
32-
var empty = Build((IIdentifiable) null, new List<AttrAttribute>(), new List<RelationshipAttribute>());
32+
var empty = Build((IIdentifiable) null, Array.Empty<AttrAttribute>(), Array.Empty<RelationshipAttribute>());
3333
return SerializeObject(empty, _jsonSerializerSettings);
3434
}
3535

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

5353
if (entity == null)
5454
{
55-
var result = Build(entities, new List<AttrAttribute>(), new List<RelationshipAttribute>());
55+
var result = Build(entities, Array.Empty<AttrAttribute>(), Array.Empty<RelationshipAttribute>());
5656
return SerializeObject(result, _jsonSerializerSettings);
5757
}
5858

src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public DeserializedListResponse<TResource> DeserializeList<TResource>(string bod
3737
{
3838
Links = _document.Links,
3939
Meta = _document.Meta,
40-
Data = ((List<IIdentifiable>) entities)?.Cast<TResource>().ToList(),
40+
Data = ((ICollection<IIdentifiable>) entities)?.Cast<TResource>().ToList(),
4141
JsonApi = null,
4242
Errors = null
4343
};

src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ protected object Deserialize(string body)
5151
if (_document.IsManyData)
5252
{
5353
if (_document.ManyData.Count == 0)
54-
return new List<IIdentifiable>();
54+
return Array.Empty<IIdentifiable>();
5555

5656
return _document.ManyData.Select(ParseResourceObject).ToList();
5757
}

0 commit comments

Comments
 (0)