Skip to content

Commit 36e1f16

Browse files
Bart Koelmanbkoelman
Bart Koelman
authored andcommitted
Added ConcurrencyValue to ensure incoming left/right versions are both checked during update
1 parent a5c164b commit 36e1f16

31 files changed

+3830
-78
lines changed

JsonApiDotNetCore.sln.DotSettings

+1
Original file line numberDiff line numberDiff line change
@@ -645,5 +645,6 @@ $left$ = $right$;</s:String>
645645
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
646646
<s:Boolean x:Key="/Default/UserDictionary/Words/=unarchive/@EntryIndexedValue">True</s:Boolean>
647647
<s:Boolean x:Key="/Default/UserDictionary/Words/=Workflows/@EntryIndexedValue">True</s:Boolean>
648+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmin/@EntryIndexedValue">True</s:Boolean>
648649
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
649650
</wpf:ResourceDictionary>
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using JetBrains.Annotations;
22
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
34
using JsonApiDotNetCore.Queries;
45
using JsonApiDotNetCore.Repositories;
56
using JsonApiDotNetCore.Resources;
@@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
1112
public sealed class DbContextARepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
1213
where TResource : class, IIdentifiable<int>
1314
{
14-
public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver, IResourceGraph resourceGraph,
15-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
16-
IResourceDefinitionAccessor resourceDefinitionAccessor)
17-
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
15+
public DbContextARepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver,
16+
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
17+
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
18+
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
1819
{
1920
}
2021
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using JetBrains.Annotations;
22
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
34
using JsonApiDotNetCore.Queries;
45
using JsonApiDotNetCore.Repositories;
56
using JsonApiDotNetCore.Resources;
@@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
1112
public sealed class DbContextBRepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
1213
where TResource : class, IIdentifiable<int>
1314
{
14-
public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver, IResourceGraph resourceGraph,
15-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
16-
IResourceDefinitionAccessor resourceDefinitionAccessor)
17-
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
15+
public DbContextBRepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver,
16+
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
17+
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
18+
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
1819
{
1920
}
2021
}

src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using JetBrains.Annotations;
2+
13
namespace JsonApiDotNetCore.Resources;
24

35
/// <summary>
@@ -21,10 +23,16 @@ public interface IVersionedIdentifiable : IIdentifiable
2123
/// <typeparam name="TVersion">
2224
/// The database vendor-specific type that is used to store the concurrency token.
2325
/// </typeparam>
26+
[PublicAPI]
2427
public interface IVersionedIdentifiable<TId, TVersion> : IIdentifiable<TId>, IVersionedIdentifiable
2528
{
2629
/// <summary>
2730
/// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved.
2831
/// </summary>
2932
TVersion ConcurrencyToken { get; set; }
33+
34+
/// <summary>
35+
/// Represents a database column where random data is written to on updates, in order to force a concurrency check during relationship updates.
36+
/// </summary>
37+
Guid ConcurrencyValue { get; set; }
3038
}

src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs

+19
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ private ICollection<PropertySelector> ToPropertySelectors(FieldSelectors fieldSe
143143
}
144144

145145
IncludeFields(fieldSelectors, propertySelectors);
146+
147+
// Implicitly add concurrency tokens, which we need for rendering links, but may not be exposed as attributes.
148+
IncludeConcurrencyTokens(resourceType, elementType, propertySelectors);
149+
146150
IncludeEagerLoads(resourceType, propertySelectors);
147151

148152
return propertySelectors.Values;
@@ -169,6 +173,21 @@ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary<Prop
169173
}
170174
}
171175

176+
private void IncludeConcurrencyTokens(ResourceType resourceType, Type elementType, Dictionary<PropertyInfo, PropertySelector> propertySelectors)
177+
{
178+
if (resourceType.IsVersioned)
179+
{
180+
IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
181+
IEnumerable<IProperty> tokenProperties = entityModel.GetProperties().Where(property => property.IsConcurrencyToken).ToArray();
182+
183+
foreach (IProperty tokenProperty in tokenProperties)
184+
{
185+
var propertySelector = new PropertySelector(tokenProperty.PropertyInfo!);
186+
IncludeWritableProperty(propertySelector, propertySelectors);
187+
}
188+
}
189+
}
190+
172191
private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary<PropertyInfo, PropertySelector> propertySelectors)
173192
{
174193
if (propertySelector.Property.SetMethod != null)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
3+
namespace JsonApiDotNetCore.Repositories;
4+
5+
/// <summary>
6+
/// The error that is thrown when the resource version from the request does not match the server version.
7+
/// </summary>
8+
[PublicAPI]
9+
public sealed class DataStoreConcurrencyException : DataStoreUpdateException
10+
{
11+
public DataStoreConcurrencyException(Exception? innerException)
12+
: base("The resource version does not match the server version. This indicates that data has been modified since the resource was retrieved.",
13+
innerException)
14+
{
15+
}
16+
}

src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs

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

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+36-7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class EntityFrameworkCoreRepository<TResource, TId> : IResourceRepository
2626
where TResource : class, IIdentifiable<TId>
2727
{
2828
private readonly CollectionConverter _collectionConverter = new();
29+
private readonly IJsonApiRequest _request;
2930
private readonly ITargetedFields _targetedFields;
3031
private readonly DbContext _dbContext;
3132
private readonly IResourceGraph _resourceGraph;
@@ -37,24 +38,26 @@ public class EntityFrameworkCoreRepository<TResource, TId> : IResourceRepository
3738
/// <inheritdoc />
3839
public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString();
3940

40-
public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph,
41-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
42-
IResourceDefinitionAccessor resourceDefinitionAccessor)
41+
public EntityFrameworkCoreRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver,
42+
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
43+
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
4344
{
45+
ArgumentGuard.NotNull(request, nameof(request));
4446
ArgumentGuard.NotNull(targetedFields, nameof(targetedFields));
4547
ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver));
4648
ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph));
4749
ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory));
50+
ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor));
4851
ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders));
4952
ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory));
50-
ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor));
5153

54+
_request = request;
5255
_targetedFields = targetedFields;
5356
_dbContext = dbContextResolver.GetContext();
5457
_resourceGraph = resourceGraph;
5558
_resourceFactory = resourceFactory;
56-
_constraintProviders = constraintProviders;
5759
_resourceDefinitionAccessor = resourceDefinitionAccessor;
60+
_constraintProviders = constraintProviders;
5861
_traceWriter = new TraceLogWriter<EntityFrameworkCoreRepository<TResource, TId>>(loggerFactory);
5962
}
6063

@@ -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 />
@@ -324,6 +331,7 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C
324331
// If so, we'll reuse the tracked resource instead of this placeholder resource.
325332
TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance<TResource>();
326333
placeholderResource.Id = id;
334+
placeholderResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
327335

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

@@ -533,6 +541,17 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
533541

534542
if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored))
535543
{
544+
if (relationship.RightType.IsVersioned)
545+
{
546+
foreach (IIdentifiable rightResource in rightResourceIdsStored)
547+
{
548+
string? requestVersion = rightResourceIdsToRemove.Single(resource => resource.StringId == rightResource.StringId).GetVersion();
549+
550+
rightResource.RestoreConcurrencyToken(_dbContext, requestVersion);
551+
rightResource.RefreshConcurrencyValue();
552+
}
553+
}
554+
536555
AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore);
537556

538557
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken);
@@ -566,6 +585,9 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
566585
await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken);
567586
}
568587

588+
leftResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
589+
leftResource.RefreshConcurrencyValue();
590+
569591
relationship.SetValue(leftResource, trackedValueToAssign);
570592
}
571593

@@ -579,6 +601,13 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
579601
IReadOnlyCollection<IIdentifiable> rightResources = _collectionConverter.ExtractResources(rightValue);
580602
IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray();
581603

604+
foreach (IIdentifiable rightResourceTracked in rightResourcesTracked)
605+
{
606+
string? rightVersion = rightResourceTracked.GetVersion();
607+
rightResourceTracked.RestoreConcurrencyToken(_dbContext, rightVersion);
608+
rightResourceTracked.RefreshConcurrencyValue();
609+
}
610+
582611
return rightValue is IEnumerable
583612
? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType)
584613
: rightResourcesTracked.Single();
@@ -604,7 +633,7 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
604633
{
605634
_dbContext.ResetChangeTracker();
606635

607-
throw new DataStoreUpdateException(exception);
636+
throw exception is DbUpdateConcurrencyException ? new DataStoreConcurrencyException(exception) : new DataStoreUpdateException(exception);
608637
}
609638
}
610639
}

src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs

+51
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
using System.Reflection;
22
using JsonApiDotNetCore.Resources.Internal;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.ChangeTracking;
35

46
namespace JsonApiDotNetCore.Resources;
57

68
internal static class IdentifiableExtensions
79
{
810
private const string IdPropertyName = nameof(Identifiable<object>.Id);
11+
private const string ConcurrencyTokenPropertyName = nameof(IVersionedIdentifiable<object, object>.ConcurrencyToken);
12+
private const string ConcurrencyValuePropertyName = nameof(IVersionedIdentifiable<object, object>.ConcurrencyValue);
913

1014
public static object GetTypedId(this IIdentifiable identifiable)
1115
{
@@ -58,4 +62,51 @@ public static void SetVersion(this IIdentifiable identifiable, string? version)
5862
versionedIdentifiable.Version = version;
5963
}
6064
}
65+
66+
public static void RestoreConcurrencyToken(this IIdentifiable identifiable, DbContext dbContext, string? versionFromRequest)
67+
{
68+
ArgumentGuard.NotNull(identifiable, nameof(identifiable));
69+
ArgumentGuard.NotNull(dbContext, nameof(dbContext));
70+
71+
if (identifiable is IVersionedIdentifiable versionedIdentifiable)
72+
{
73+
versionedIdentifiable.Version = versionFromRequest;
74+
75+
PropertyInfo? property = identifiable.GetClrType().GetProperty(ConcurrencyTokenPropertyName);
76+
77+
if (property == null)
78+
{
79+
throw new InvalidOperationException(
80+
$"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{ConcurrencyTokenPropertyName}'.");
81+
}
82+
83+
PropertyEntry propertyEntry = dbContext.Entry(identifiable).Property(ConcurrencyTokenPropertyName);
84+
85+
if (!propertyEntry.Metadata.IsConcurrencyToken)
86+
{
87+
throw new InvalidOperationException($"Property '{identifiable.GetClrType()}.{ConcurrencyTokenPropertyName}' is not a concurrency token.");
88+
}
89+
90+
object? concurrencyTokenFromRequest = property.GetValue(identifiable);
91+
propertyEntry.OriginalValue = concurrencyTokenFromRequest;
92+
}
93+
}
94+
95+
public static void RefreshConcurrencyValue(this IIdentifiable identifiable)
96+
{
97+
ArgumentGuard.NotNull(identifiable, nameof(identifiable));
98+
99+
if (identifiable is IVersionedIdentifiable)
100+
{
101+
PropertyInfo? property = identifiable.GetClrType().GetProperty(ConcurrencyValuePropertyName);
102+
103+
if (property == null)
104+
{
105+
throw new InvalidOperationException(
106+
$"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{ConcurrencyValuePropertyName}'.");
107+
}
108+
109+
property.SetValue(identifiable, Guid.NewGuid());
110+
}
111+
}
61112
}

src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs

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

16-
private IDictionary<string, string>? _initiallyStoredAttributeValues;
17-
private IDictionary<string, string>? _requestAttributeValues;
18-
private IDictionary<string, string>? _finallyStoredAttributeValues;
16+
private IDictionary<string, string?>? _initiallyStoredAttributeValues;
17+
private IDictionary<string, string?>? _requestAttributeValues;
18+
private IDictionary<string, string?>? _finallyStoredAttributeValues;
1919

2020
public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields)
2121
{
@@ -50,9 +50,9 @@ public void SetFinallyStoredAttributeValues(TResource resource)
5050
_finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes);
5151
}
5252

53-
private IDictionary<string, string> CreateAttributeDictionary(TResource resource, IEnumerable<AttrAttribute> attributes)
53+
private IDictionary<string, string?> CreateAttributeDictionary(TResource resource, IEnumerable<AttrAttribute> attributes)
5454
{
55-
var result = new Dictionary<string, string>();
55+
var result = new Dictionary<string, string?>();
5656

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

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

@@ -73,7 +78,7 @@ public bool HasImplicitChanges()
7378
{
7479
if (_requestAttributeValues.TryGetValue(key, out string? requestValue))
7580
{
76-
string actualValue = _finallyStoredAttributeValues[key];
81+
string? actualValue = _finallyStoredAttributeValues[key];
7782

7883
if (requestValue != actualValue)
7984
{
@@ -82,8 +87,8 @@ public bool HasImplicitChanges()
8287
}
8388
else
8489
{
85-
string initiallyStoredValue = _initiallyStoredAttributeValues[key];
86-
string finallyStoredValue = _finallyStoredAttributeValues[key];
90+
string? initiallyStoredValue = _initiallyStoredAttributeValues[key];
91+
string? finallyStoredValue = _finallyStoredAttributeValues[key];
8792

8893
if (initiallyStoredValue != finallyStoredValue)
8994
{

0 commit comments

Comments
 (0)