Skip to content

Commit 73f8832

Browse files
author
Bart Koelman
committed
Added ConcurrencyValue to ensure incoming left/right versions are both checked during update
1 parent fe932b8 commit 73f8832

38 files changed

+3960
-78
lines changed

Diff for: JsonApiDotNetCore.sln.DotSettings

+1
Original file line numberDiff line numberDiff line change
@@ -637,5 +637,6 @@ $left$ = $right$;</s:String>
637637
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
638638
<s:Boolean x:Key="/Default/UserDictionary/Words/=unarchive/@EntryIndexedValue">True</s:Boolean>
639639
<s:Boolean x:Key="/Default/UserDictionary/Words/=Workflows/@EntryIndexedValue">True</s:Boolean>
640+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmin/@EntryIndexedValue">True</s:Boolean>
640641
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
641642
</wpf:ResourceDictionary>

Diff for: src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Generic;
22
using JetBrains.Annotations;
33
using JsonApiDotNetCore.Configuration;
4+
using JsonApiDotNetCore.Middleware;
45
using JsonApiDotNetCore.Queries;
56
using JsonApiDotNetCore.Repositories;
67
using JsonApiDotNetCore.Resources;
@@ -13,10 +14,10 @@ namespace MultiDbContextExample.Repositories
1314
public sealed class DbContextARepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
1415
where TResource : class, IIdentifiable<int>
1516
{
16-
public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver, IResourceGraph resourceGraph,
17-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
18-
IResourceDefinitionAccessor resourceDefinitionAccessor)
19-
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
17+
public DbContextARepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver,
18+
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
19+
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
20+
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
2021
{
2122
}
2223
}

Diff for: src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Generic;
22
using JetBrains.Annotations;
33
using JsonApiDotNetCore.Configuration;
4+
using JsonApiDotNetCore.Middleware;
45
using JsonApiDotNetCore.Queries;
56
using JsonApiDotNetCore.Repositories;
67
using JsonApiDotNetCore.Resources;
@@ -13,10 +14,10 @@ namespace MultiDbContextExample.Repositories
1314
public sealed class DbContextBRepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
1415
where TResource : class, IIdentifiable<int>
1516
{
16-
public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver, IResourceGraph resourceGraph,
17-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
18-
IResourceDefinitionAccessor resourceDefinitionAccessor)
19-
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
17+
public DbContextBRepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver,
18+
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
19+
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
20+
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
2021
{
2122
}
2223
}

Diff for: src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs

+18
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ private ICollection<PropertySelector> ToPropertySelectors(IDictionary<ResourceFi
100100

101101
IncludeFieldSelection(resourceFieldSelectors, propertySelectors);
102102

103+
// Implicitly add concurrency tokens, which we need for rendering links, but may not be exposed as attributes.
104+
IncludeConcurrencyTokens(resourceType, elementType, propertySelectors);
105+
103106
IncludeEagerLoads(resourceType, propertySelectors);
104107

105108
return propertySelectors.Values;
@@ -127,6 +130,21 @@ private static void IncludeFieldSelection(IDictionary<ResourceFieldAttribute, Qu
127130
}
128131
}
129132

133+
private void IncludeConcurrencyTokens(ResourceType resourceType, Type elementType, Dictionary<PropertyInfo, PropertySelector> propertySelectors)
134+
{
135+
if (resourceType.IsVersioned)
136+
{
137+
IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
138+
IEnumerable<IProperty> tokenProperties = entityModel.GetProperties().Where(property => property.IsConcurrencyToken).ToArray();
139+
140+
foreach (var tokenProperty in tokenProperties)
141+
{
142+
var propertySelector = new PropertySelector(tokenProperty.PropertyInfo);
143+
IncludeWritableProperty(propertySelector, propertySelectors);
144+
}
145+
}
146+
}
147+
130148
private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary<PropertyInfo, PropertySelector> propertySelectors)
131149
{
132150
if (propertySelector.Property.SetMethod != null)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System;
2+
using JetBrains.Annotations;
3+
4+
namespace JsonApiDotNetCore.Repositories
5+
{
6+
/// <summary>
7+
/// The error that is thrown when the resource version from the request does not match the server version.
8+
/// </summary>
9+
[PublicAPI]
10+
public sealed class DataStoreConcurrencyException : DataStoreUpdateException
11+
{
12+
public DataStoreConcurrencyException(Exception? innerException)
13+
: base("The resource version does not match the server version. This indicates that data has been modified since the resource was retrieved.",
14+
innerException)
15+
{
16+
}
17+
}
18+
}

Diff for: src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ namespace JsonApiDotNetCore.Repositories
77
/// The error that is thrown when the underlying data store is unable to persist changes.
88
/// </summary>
99
[PublicAPI]
10-
public sealed class DataStoreUpdateException : Exception
10+
public class DataStoreUpdateException : Exception
1111
{
1212
public DataStoreUpdateException(Exception? innerException)
13-
: base("Failed to persist changes in the underlying data store.", innerException)
13+
: this("Failed to persist changes in the underlying data store.", innerException)
14+
{
15+
}
16+
17+
protected DataStoreUpdateException(string message, Exception? innerException)
18+
: base(message, innerException)
1419
{
1520
}
1621
}

Diff for: src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+36-7
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public class EntityFrameworkCoreRepository<TResource, TId> : IResourceRepository
3131
where TResource : class, IIdentifiable<TId>
3232
{
3333
private readonly CollectionConverter _collectionConverter = new();
34+
private readonly IJsonApiRequest _request;
3435
private readonly ITargetedFields _targetedFields;
3536
private readonly DbContext _dbContext;
3637
private readonly IResourceGraph _resourceGraph;
@@ -42,24 +43,26 @@ public class EntityFrameworkCoreRepository<TResource, TId> : IResourceRepository
4243
/// <inheritdoc />
4344
public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString();
4445

45-
public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph,
46-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
47-
IResourceDefinitionAccessor resourceDefinitionAccessor)
46+
public EntityFrameworkCoreRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver,
47+
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
48+
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
4849
{
50+
ArgumentGuard.NotNull(request, nameof(request));
4951
ArgumentGuard.NotNull(targetedFields, nameof(targetedFields));
5052
ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver));
5153
ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph));
5254
ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory));
55+
ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor));
5356
ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders));
5457
ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory));
55-
ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor));
5658

59+
_request = request;
5760
_targetedFields = targetedFields;
5861
_dbContext = dbContextResolver.GetContext();
5962
_resourceGraph = resourceGraph;
6063
_resourceFactory = resourceFactory;
61-
_constraintProviders = constraintProviders;
6264
_resourceDefinitionAccessor = resourceDefinitionAccessor;
65+
_constraintProviders = constraintProviders;
6366
_traceWriter = new TraceLogWriter<EntityFrameworkCoreRepository<TResource, TId>>(loggerFactory);
6467
}
6568

@@ -249,7 +252,11 @@ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, has
249252
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update");
250253

251254
IReadOnlyCollection<TResource> resources = await GetAsync(queryLayer, cancellationToken);
252-
return resources.FirstOrDefault();
255+
TResource? resource = resources.FirstOrDefault();
256+
257+
resource?.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
258+
259+
return resource;
253260
}
254261

255262
/// <inheritdoc />
@@ -323,6 +330,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
323330
// If so, we'll reuse the tracked resource instead of this placeholder resource.
324331
var placeholderResource = _resourceFactory.CreateInstance<TResource>();
325332
placeholderResource.Id = id;
333+
placeholderResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
326334

327335
await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken);
328336

@@ -502,6 +510,17 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
502510

503511
if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored))
504512
{
513+
if (relationship.RightType.IsVersioned)
514+
{
515+
foreach (IIdentifiable rightResource in rightResourceIdsStored)
516+
{
517+
string? requestVersion = rightResourceIdsToRemove.Single(resource => resource.StringId == rightResource.StringId).GetVersion();
518+
519+
rightResource.RestoreConcurrencyToken(_dbContext, requestVersion);
520+
rightResource.RefreshConcurrencyValue();
521+
}
522+
}
523+
505524
AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore);
506525

507526
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken);
@@ -535,6 +554,9 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
535554
await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken);
536555
}
537556

557+
leftResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
558+
leftResource.RefreshConcurrencyValue();
559+
538560
relationship.SetValue(leftResource, trackedValueToAssign);
539561
}
540562

@@ -548,6 +570,13 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
548570
ICollection<IIdentifiable> rightResources = _collectionConverter.ExtractResources(rightValue);
549571
IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray();
550572

573+
foreach (var rightResourceTracked in rightResourcesTracked)
574+
{
575+
string? rightVersion = rightResourceTracked.GetVersion();
576+
rightResourceTracked.RestoreConcurrencyToken(_dbContext, rightVersion);
577+
rightResourceTracked.RefreshConcurrencyValue();
578+
}
579+
551580
return rightValue is IEnumerable
552581
? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType)
553582
: rightResourcesTracked.Single();
@@ -573,7 +602,7 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
573602
{
574603
_dbContext.ResetChangeTracker();
575604

576-
throw new DataStoreUpdateException(exception);
605+
throw exception is DbUpdateConcurrencyException ? new DataStoreConcurrencyException(exception) : new DataStoreUpdateException(exception);
577606
}
578607
}
579608
}

Diff for: src/JsonApiDotNetCore/Resources/IVersionedIdentifiable.cs

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using System;
2+
using JetBrains.Annotations;
3+
14
namespace JsonApiDotNetCore.Resources
25
{
36
/// <summary>
@@ -21,11 +24,17 @@ public interface IVersionedIdentifiable : IIdentifiable
2124
/// <typeparam name="TVersion">
2225
/// The database vendor-specific type that is used to store the concurrency token.
2326
/// </typeparam>
27+
[PublicAPI]
2428
public interface IVersionedIdentifiable<TId, TVersion> : IIdentifiable<TId>, IVersionedIdentifiable
2529
{
2630
/// <summary>
2731
/// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved.
2832
/// </summary>
2933
TVersion ConcurrencyToken { get; set; }
34+
35+
/// <summary>
36+
/// Represents a database column where random data is written to on updates, in order to force a concurrency check during relationship updates.
37+
/// </summary>
38+
Guid ConcurrencyValue { get; set; }
3039
}
3140
}

Diff for: src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs

+51
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
using System;
22
using System.Reflection;
33
using JsonApiDotNetCore.Resources.Internal;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.EntityFrameworkCore.ChangeTracking;
46

57
namespace JsonApiDotNetCore.Resources
68
{
79
internal static class IdentifiableExtensions
810
{
911
private const string IdPropertyName = nameof(Identifiable<object>.Id);
12+
private const string ConcurrencyTokenPropertyName = nameof(IVersionedIdentifiable<object, object>.ConcurrencyToken);
13+
private const string ConcurrencyValuePropertyName = nameof(IVersionedIdentifiable<object, object>.ConcurrencyValue);
1014

1115
public static object GetTypedId(this IIdentifiable identifiable)
1216
{
@@ -52,5 +56,52 @@ public static void SetVersion(this IIdentifiable identifiable, string? version)
5256
versionedIdentifiable.Version = version;
5357
}
5458
}
59+
60+
public static void RestoreConcurrencyToken(this IIdentifiable identifiable, DbContext dbContext, string? versionFromRequest)
61+
{
62+
ArgumentGuard.NotNull(identifiable, nameof(identifiable));
63+
ArgumentGuard.NotNull(dbContext, nameof(dbContext));
64+
65+
if (identifiable is IVersionedIdentifiable versionedIdentifiable)
66+
{
67+
versionedIdentifiable.Version = versionFromRequest;
68+
69+
PropertyInfo? property = identifiable.GetType().GetProperty(ConcurrencyTokenPropertyName);
70+
71+
if (property == null)
72+
{
73+
throw new InvalidOperationException(
74+
$"Resource of type '{identifiable.GetType()}' does not contain a property named '{ConcurrencyTokenPropertyName}'.");
75+
}
76+
77+
PropertyEntry propertyEntry = dbContext.Entry(identifiable).Property(ConcurrencyTokenPropertyName);
78+
79+
if (!propertyEntry.Metadata.IsConcurrencyToken)
80+
{
81+
throw new InvalidOperationException($"Property '{identifiable.GetType()}.{ConcurrencyTokenPropertyName}' is not a concurrency token.");
82+
}
83+
84+
object? concurrencyTokenFromRequest = property.GetValue(identifiable);
85+
propertyEntry.OriginalValue = concurrencyTokenFromRequest;
86+
}
87+
}
88+
89+
public static void RefreshConcurrencyValue(this IIdentifiable identifiable)
90+
{
91+
ArgumentGuard.NotNull(identifiable, nameof(identifiable));
92+
93+
if (identifiable is IVersionedIdentifiable)
94+
{
95+
PropertyInfo? property = identifiable.GetType().GetProperty(ConcurrencyValuePropertyName);
96+
97+
if (property == null)
98+
{
99+
throw new InvalidOperationException(
100+
$"Resource of type '{identifiable.GetType()}' does not contain a property named '{ConcurrencyValuePropertyName}'.");
101+
}
102+
103+
property.SetValue(identifiable, Guid.NewGuid());
104+
}
105+
}
55106
}
56107
}

Diff for: src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs

+13-8
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ public sealed class ResourceChangeTracker<TResource> : IResourceChangeTracker<TR
1414
private readonly ResourceType _resourceType;
1515
private readonly ITargetedFields _targetedFields;
1616

17-
private IDictionary<string, string>? _initiallyStoredAttributeValues;
18-
private IDictionary<string, string>? _requestAttributeValues;
19-
private IDictionary<string, string>? _finallyStoredAttributeValues;
17+
private IDictionary<string, string?>? _initiallyStoredAttributeValues;
18+
private IDictionary<string, string?>? _requestAttributeValues;
19+
private IDictionary<string, string?>? _finallyStoredAttributeValues;
2020

2121
public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields)
2222
{
@@ -51,9 +51,9 @@ public void SetFinallyStoredAttributeValues(TResource resource)
5151
_finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes);
5252
}
5353

54-
private IDictionary<string, string> CreateAttributeDictionary(TResource resource, IEnumerable<AttrAttribute> attributes)
54+
private IDictionary<string, string?> CreateAttributeDictionary(TResource resource, IEnumerable<AttrAttribute> attributes)
5555
{
56-
var result = new Dictionary<string, string>();
56+
var result = new Dictionary<string, string?>();
5757

5858
foreach (AttrAttribute attribute in attributes)
5959
{
@@ -62,6 +62,11 @@ private IDictionary<string, string> CreateAttributeDictionary(TResource resource
6262
result.Add(attribute.PublicName, json);
6363
}
6464

65+
if (resource is IVersionedIdentifiable versionedIdentifiable)
66+
{
67+
result.Add(nameof(versionedIdentifiable.Version), versionedIdentifiable.Version);
68+
}
69+
6570
return result;
6671
}
6772

@@ -74,7 +79,7 @@ public bool HasImplicitChanges()
7479
{
7580
if (_requestAttributeValues.TryGetValue(key, out string? requestValue))
7681
{
77-
string actualValue = _finallyStoredAttributeValues[key];
82+
string? actualValue = _finallyStoredAttributeValues[key];
7883

7984
if (requestValue != actualValue)
8085
{
@@ -83,8 +88,8 @@ public bool HasImplicitChanges()
8388
}
8489
else
8590
{
86-
string initiallyStoredValue = _initiallyStoredAttributeValues[key];
87-
string finallyStoredValue = _finallyStoredAttributeValues[key];
91+
string? initiallyStoredValue = _initiallyStoredAttributeValues[key];
92+
string? finallyStoredValue = _finallyStoredAttributeValues[key];
8893

8994
if (initiallyStoredValue != finallyStoredValue)
9095
{

0 commit comments

Comments
 (0)