Skip to content
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

Explores support for concurrency tokens using PostgreSQL, continued #1119

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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 @@ -660,5 +660,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>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
Expand All @@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
public sealed class DbContextARepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
where TResource : class, IIdentifiable<int>
{
public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver, IResourceGraph resourceGraph,
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
IResourceDefinitionAccessor resourceDefinitionAccessor)
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
public DbContextARepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver,
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
Expand All @@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
public sealed class DbContextBRepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
where TResource : class, IIdentifiable<int>
{
public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver, IResourceGraph resourceGraph,
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
IResourceDefinitionAccessor resourceDefinitionAccessor)
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
public DbContextBRepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver,
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.Configuration;
Expand Down Expand Up @@ -38,6 +39,11 @@ public sealed class ResourceType
/// </summary>
public IReadOnlySet<ResourceType> DirectlyDerivedTypes { get; internal set; } = new HashSet<ResourceType>();

/// <summary>
/// When <c>true</c>, this resource type uses optimistic concurrency.
/// </summary>
public bool IsVersioned => ClrType.IsOrImplementsInterface<IVersionedIdentifiable>();

/// <summary>
/// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. When using resource inheritance, this
/// includes the attributes and relationships from base types.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using JetBrains.Annotations;

namespace JsonApiDotNetCore.Resources;

/// <summary>
/// Defines the basic contract for a JSON:API resource that uses optimistic concurrency. All resource classes must implement
/// <see cref="IVersionedIdentifiable{TId, TVersion}" />.
/// </summary>
public interface IVersionedIdentifiable : IIdentifiable
{
/// <summary>
/// The value for element 'version' in a JSON:API request or response.
/// </summary>
string? Version { get; set; }
}

/// <summary>
/// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource that uses optimistic concurrency.
/// </summary>
/// <typeparam name="TId">
/// The resource identifier type.
/// </typeparam>
/// <typeparam name="TVersion">
/// The database vendor-specific type that is used to store the concurrency token.
/// </typeparam>
[PublicAPI]
public interface IVersionedIdentifiable<TId, TVersion> : IIdentifiable<TId>, IVersionedIdentifiable
{
/// <summary>
/// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved.
/// </summary>
TVersion ConcurrencyToken { get; set; }

/// <summary>
/// Represents a database column where random data is written to on updates, in order to force a concurrency check during relationship updates.
/// </summary>
Guid ConcurrencyValue { get; set; }
}
2 changes: 1 addition & 1 deletion src/JsonApiDotNetCore.Annotations/TypeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public static bool IsOrImplementsInterface<TInterface>(this Type? source)
/// <summary>
/// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface.
/// </summary>
private static bool IsOrImplementsInterface(this Type? source, Type interfaceType)
public static bool IsOrImplementsInterface(this Type? source, Type interfaceType)
{
ArgumentGuard.NotNull(interfaceType);

Expand Down
13 changes: 13 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Resources;

namespace JsonApiDotNetCore.AtomicOperations;

public interface IVersionTracker
{
bool RequiresVersionTracking();

void CaptureVersions(ResourceType resourceType, IIdentifiable resource);

string? GetVersion(ResourceType resourceType, string stringId);
}
41 changes: 40 additions & 1 deletion src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ public class OperationsProcessor : IOperationsProcessor
private readonly IOperationProcessorAccessor _operationProcessorAccessor;
private readonly IOperationsTransactionFactory _operationsTransactionFactory;
private readonly ILocalIdTracker _localIdTracker;
private readonly IVersionTracker _versionTracker;
private readonly IResourceGraph _resourceGraph;
private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
private readonly ISparseFieldSetCache _sparseFieldSetCache;
private readonly LocalIdValidator _localIdValidator;

public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory,
ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields,
ILocalIdTracker localIdTracker, IVersionTracker versionTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields,
ISparseFieldSetCache sparseFieldSetCache)
{
ArgumentGuard.NotNull(operationProcessorAccessor);
ArgumentGuard.NotNull(operationsTransactionFactory);
ArgumentGuard.NotNull(localIdTracker);
ArgumentGuard.NotNull(versionTracker);
ArgumentGuard.NotNull(resourceGraph);
ArgumentGuard.NotNull(request);
ArgumentGuard.NotNull(targetedFields);
Expand All @@ -36,6 +38,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
_operationProcessorAccessor = operationProcessorAccessor;
_operationsTransactionFactory = operationsTransactionFactory;
_localIdTracker = localIdTracker;
_versionTracker = versionTracker;
_resourceGraph = resourceGraph;
_request = request;
_targetedFields = targetedFields;
Expand Down Expand Up @@ -104,11 +107,15 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
cancellationToken.ThrowIfCancellationRequested();

TrackLocalIdsForOperation(operation);
RefreshVersionsForOperation(operation);

_targetedFields.CopyFrom(operation.TargetedFields);
_request.CopyFrom(operation.Request);

return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken);

// Ideally we'd take the versions from response here and update the version cache, but currently
// not all resource service methods return data. Therefore this is handled elsewhere.
}

protected void TrackLocalIdsForOperation(OperationContainer operation)
Expand Down Expand Up @@ -144,4 +151,36 @@ private void AssignStringId(IIdentifiable resource)
resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType);
}
}

private void RefreshVersionsForOperation(OperationContainer operation)
{
if (operation.Request.PrimaryResourceType!.IsVersioned)
{
string? requestVersion = operation.Resource.GetVersion();

if (requestVersion == null)
{
string? trackedVersion = _versionTracker.GetVersion(operation.Request.PrimaryResourceType, operation.Resource.StringId!);
operation.Resource.SetVersion(trackedVersion);

((JsonApiRequest)operation.Request).PrimaryVersion = trackedVersion;
}
}

foreach (IIdentifiable rightResource in operation.GetSecondaryResources())
{
ResourceType rightResourceType = _resourceGraph.GetResourceType(rightResource.GetClrType());

if (rightResourceType.IsVersioned)
{
string? requestVersion = rightResource.GetVersion();

if (requestVersion == null)
{
string? trackedVersion = _versionTracker.GetVersion(rightResourceType, rightResource.StringId!);
rightResource.SetVersion(trackedVersion);
}
}
}
}
}
91 changes: 91 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.AtomicOperations;

public sealed class VersionTracker : IVersionTracker
{
private static readonly CollectionConverter CollectionConverter = new();

private readonly ITargetedFields _targetedFields;
private readonly IJsonApiRequest _request;
private readonly Dictionary<string, string> _versionPerResource = new();

public VersionTracker(ITargetedFields targetedFields, IJsonApiRequest request)
{
ArgumentGuard.NotNull(targetedFields, nameof(targetedFields));
ArgumentGuard.NotNull(request, nameof(request));

_targetedFields = targetedFields;
_request = request;
}

public bool RequiresVersionTracking()
{
if (_request.Kind != EndpointKind.AtomicOperations)
{
return false;
}

return _request.PrimaryResourceType!.IsVersioned || _targetedFields.Relationships.Any(relationship => relationship.RightType.IsVersioned);
}

public void CaptureVersions(ResourceType resourceType, IIdentifiable resource)
{
if (_request.Kind == EndpointKind.AtomicOperations)
{
if (resourceType.IsVersioned)
{
string? leftVersion = resource.GetVersion();
SetVersion(resourceType, resource.StringId!, leftVersion);
}

foreach (RelationshipAttribute relationship in _targetedFields.Relationships)
{
if (relationship.RightType.IsVersioned)
{
CaptureVersionsInRelationship(resource, relationship);
}
}
}
}

private void CaptureVersionsInRelationship(IIdentifiable resource, RelationshipAttribute relationship)
{
object? afterRightValue = relationship.GetValue(resource);
IReadOnlyCollection<IIdentifiable> afterRightResources = CollectionConverter.ExtractResources(afterRightValue);

foreach (IIdentifiable rightResource in afterRightResources)
{
string? rightVersion = rightResource.GetVersion();
SetVersion(relationship.RightType, rightResource.StringId!, rightVersion);
}
}

private void SetVersion(ResourceType resourceType, string stringId, string? version)
{
string key = GetKey(resourceType, stringId);

if (version == null)
{
_versionPerResource.Remove(key);
}
else
{
_versionPerResource[key] = version;
}
}

public string? GetVersion(ResourceType resourceType, string stringId)
{
string key = GetKey(resourceType, stringId);
return _versionPerResource.TryGetValue(key, out string? version) ? version : null;
}

private string GetKey(ResourceType resourceType, string stringId)
{
return $"{resourceType.PublicName}::{stringId}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ private void AddOperationsLayer()
_services.AddScoped<IOperationsProcessor, OperationsProcessor>();
_services.AddScoped<IOperationProcessorAccessor, OperationProcessorAccessor>();
_services.AddScoped<ILocalIdTracker, LocalIdTracker>();
_services.AddScoped<IVersionTracker, VersionTracker>();
}

public void Dispose()
Expand Down
6 changes: 6 additions & 0 deletions src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st
return this;
}

if (resourceClrType.IsOrImplementsInterface<IVersionedIdentifiable>() && !resourceClrType.IsOrImplementsInterface(typeof(IVersionedIdentifiable<,>)))
{
throw new InvalidConfigurationException(
$"Resource type '{resourceClrType}' implements 'IVersionedIdentifiable', but not 'IVersionedIdentifiable<TId, TVersion>'.");
}

if (resourceClrType.IsOrImplementsInterface<IIdentifiable>())
{
string effectivePublicName = publicName ?? FormatResourceName(resourceClrType);
Expand Down
23 changes: 21 additions & 2 deletions src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,9 @@ public virtual async Task<IActionResult> PostAsync([FromBody] TResource resource

TResource? newResource = await _create.CreateAsync(resource, cancellationToken);

string resourceId = (newResource ?? resource).StringId!;
string locationUrl = $"{HttpContext.Request.Path}/{resourceId}";
TResource resultResource = newResource ?? resource;
string? resourceVersion = resultResource.GetVersion();
string locationUrl = $"{HttpContext.Request.Path}/{resultResource.StringId}{(resourceVersion != null ? $";v~{resourceVersion}" : null)}";

if (newResource == null)
{
Expand All @@ -221,6 +222,9 @@ public virtual async Task<IActionResult> PostAsync([FromBody] TResource resource
/// <summary>
/// Adds resources to a to-many relationship. Example: <code><![CDATA[
/// POST /articles/1/revisions HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// POST /articles/1;v~8/revisions HTTP/1.1
/// ]]></code>
/// </summary>
/// <param name="id">
Expand Down Expand Up @@ -262,6 +266,9 @@ public virtual async Task<IActionResult> PostRelationshipAsync(TId id, string re
/// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent
/// relationships are replaced. Example: <code><![CDATA[
/// PATCH /articles/1 HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1;v~8 HTTP/1.1
/// ]]></code>
/// </summary>
public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken)
Expand Down Expand Up @@ -295,7 +302,13 @@ public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource
/// PATCH /articles/1/relationships/author HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1;v~8/relationships/author HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1/relationships/revisions HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1;v~8/relationships/revisions HTTP/1.1
/// ]]></code>
/// </summary>
/// <param name="id">
Expand Down Expand Up @@ -335,6 +348,9 @@ public virtual async Task<IActionResult> PatchRelationshipAsync(TId id, string r
/// <summary>
/// Deletes an existing resource. Example: <code><![CDATA[
/// DELETE /articles/1 HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// DELETE /articles/1;v~8 HTTP/1.1
/// ]]></code>
/// </summary>
public virtual async Task<IActionResult> DeleteAsync(TId id, CancellationToken cancellationToken)
Expand All @@ -357,6 +373,9 @@ public virtual async Task<IActionResult> DeleteAsync(TId id, CancellationToken c
/// <summary>
/// Removes resources from a to-many relationship. Example: <code><![CDATA[
/// DELETE /articles/1/relationships/revisions HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// DELETE /articles/1;v~8/relationships/revisions HTTP/1.1
/// ]]></code>
/// </summary>
/// <param name="id">
Expand Down
Loading