Skip to content

Explores support for concurrency tokens using PostgreSQL #917

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

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions JsonApiDotNetCore.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,6 @@ $left$ = $right$;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=unarchive/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Workflows/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmin/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
</wpf:ResourceDictionary>
23 changes: 23 additions & 0 deletions src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.Serialization.Objects;

namespace JsonApiDotNetCore.Errors
{
/// <summary>
/// The error that is thrown when data has been modified on the server since the resource was retrieved.
/// </summary>
[PublicAPI]
public sealed class DataConcurrencyException : JsonApiException
{
public DataConcurrencyException(Exception exception)
: base(new ErrorObject(HttpStatusCode.Conflict)
{
Title = "The concurrency token is missing or does not match the server version. " +
"This indicates that data has been modified since the resource was retrieved."
}, exception)
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
Expand Down Expand Up @@ -207,7 +208,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r
DbSet<TResource> dbSet = _dbContext.Set<TResource>();
await dbSet.AddAsync(resourceForDatabase, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken);

Expand Down Expand Up @@ -283,15 +284,43 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r
attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest));
}

bool hasConcurrencyToken = RestoreConcurrencyToken(resourceFromRequest, resourceFromDatabase);

await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, hasConcurrencyToken);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken);

_dbContext.ResetChangeTracker();
}

private bool RestoreConcurrencyToken(TResource resourceFromRequest, TResource resourceFromDatabase)
{
bool hasConcurrencyToken = false;

foreach (var propertyEntry in _dbContext.Entry(resourceFromDatabase).Properties)
{
if (propertyEntry.Metadata.IsConcurrencyToken)
{
// Overwrite the ConcurrencyToken coming from database with the one from the request body.
// If they are different, EF Core throws a DbUpdateConcurrencyException on save.

PropertyInfo? concurrencyTokenProperty = typeof(TResource).GetProperty(propertyEntry.Metadata.PropertyInfo.Name);

if (concurrencyTokenProperty != null)
{
object? concurrencyTokenFromRequest = concurrencyTokenProperty.GetValue(resourceFromRequest);
propertyEntry.OriginalValue = concurrencyTokenFromRequest;

hasConcurrencyToken = true;
}
}
}

return hasConcurrencyToken;
}

protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue)
{
if (relationship is HasOneAttribute)
Expand Down Expand Up @@ -341,7 +370,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke

_dbContext.Remove(resourceTracked);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken);
}
Expand Down Expand Up @@ -412,7 +441,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r

await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken);
}
Expand Down Expand Up @@ -445,7 +474,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet<IIdentif

await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);
}
Expand Down Expand Up @@ -508,7 +537,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour

await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
}
Expand Down Expand Up @@ -559,7 +588,7 @@ private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship
return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true };
}

protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken)
protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken, bool hasConcurrencyToken)
{
cancellationToken.ThrowIfCancellationRequested();

Expand All @@ -571,6 +600,11 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
}
catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException)
{
if (hasConcurrencyToken && exception is DbUpdateConcurrencyException)
{
throw new DataConcurrencyException(exception);
}

_dbContext.ResetChangeTracker();

throw new DataStoreUpdateException(exception);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;

// @formatter:wrap_chained_method_calls chop_always

namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens
{
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ConcurrencyDbContext : DbContext
{
public DbSet<Disk> Disks => Set<Disk>();
public DbSet<Partition> Partitions => Set<Partition>();

public ConcurrencyDbContext(DbContextOptions<ConcurrencyDbContext> options)
: base(options)
{
}

protected override void OnModelCreating(ModelBuilder builder)
{
// https://www.npgsql.org/efcore/modeling/concurrency.html

builder.Entity<Disk>()
.UseXminAsConcurrencyToken();

builder.Entity<Partition>()
.UseXminAsConcurrencyToken();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using Bogus;
using JsonApiDotNetCore;
using TestBuildingBlocks;

// @formatter:wrap_chained_method_calls chop_always
// @formatter:keep_existing_linebreaks true

namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens
{
internal sealed class ConcurrencyFakers : FakerContainer
{
private const ulong OneGigabyte = 1024 * 1024 * 1024;
private static readonly string[] KnownFileSystems = ArrayFactory.Create("NTFS", "FAT32", "ext4", "XFS", "ZFS", "btrfs");

private readonly Lazy<Faker<Disk>> _lazyDiskFaker = new(() =>
new Faker<Disk>().UseSeed(GetFakerSeed())
.RuleFor(disk => disk.Manufacturer, faker => faker.Company.CompanyName())
.RuleFor(disk => disk.SerialCode, faker => faker.System.ApplePushToken()));

private readonly Lazy<Faker<Partition>> _lazyPartitionFaker = new(() =>
new Faker<Partition>().UseSeed(GetFakerSeed())
.RuleFor(partition => partition.MountPoint, faker => faker.System.DirectoryPath())
.RuleFor(partition => partition.FileSystem, faker => faker.PickRandom(KnownFileSystems))
.RuleFor(partition => partition.CapacityInBytes, faker => faker.Random.ULong(OneGigabyte * 50, OneGigabyte * 100))
.RuleFor(partition => partition.FreeSpaceInBytes, faker => faker.Random.ULong(OneGigabyte * 10, OneGigabyte * 40)));

public Faker<Disk> Disk => _lazyDiskFaker.Value;
public Faker<Partition> Partition => _lazyPartitionFaker.Value;
}
}
Loading