From cee13f69dc6cd3e12621cd0f96cad6ef69852131 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 30 Nov 2021 19:22:21 +0100 Subject: [PATCH] Tryout: tracking versions in atomic:operations --- .../AtomicOperations/IVersionTracker.cs | 14 + .../AtomicOperations/OperationsProcessor.cs | 43 ++- .../AtomicOperations/VersionTracker.cs | 94 ++++++ .../JsonApiApplicationBuilder.cs | 1 + .../Queries/IQueryLayerComposer.cs | 5 + .../Queries/Internal/QueryLayerComposer.cs | 39 +++ .../Services/JsonApiResourceService.cs | 34 ++- test/DiscoveryTests/PrivateResourceService.cs | 6 +- .../ServiceDiscoveryFacadeTests.cs | 2 + .../ConsumerArticleService.cs | 6 +- .../MultiTenantResourceService.cs | 6 +- .../OptimisticConcurrencyOperationsTests.cs | 275 ++++++++++++++++++ .../SoftDeletionAwareResourceService.cs | 6 +- 13 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs new file mode 100644 index 0000000000..5e76423ec9 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs @@ -0,0 +1,14 @@ +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); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index ceca03ccdf..abd16c47d1 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -19,6 +19,7 @@ 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; @@ -26,12 +27,13 @@ public class OperationsProcessor : IOperationsProcessor private readonly LocalIdValidator _localIdValidator; public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, - ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, - ISparseFieldSetCache sparseFieldSetCache) + ILocalIdTracker localIdTracker, IVersionTracker versionTracker, IResourceGraph resourceGraph, IJsonApiRequest request, + ITargetedFields targetedFields, ISparseFieldSetCache sparseFieldSetCache) { ArgumentGuard.NotNull(operationProcessorAccessor, nameof(operationProcessorAccessor)); ArgumentGuard.NotNull(operationsTransactionFactory, nameof(operationsTransactionFactory)); ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); + ArgumentGuard.NotNull(versionTracker, nameof(versionTracker)); ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); @@ -40,6 +42,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _operationProcessorAccessor = operationProcessorAccessor; _operationsTransactionFactory = operationsTransactionFactory; _localIdTracker = localIdTracker; + _versionTracker = versionTracker; _resourceGraph = resourceGraph; _request = request; _targetedFields = targetedFields; @@ -108,11 +111,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) @@ -148,5 +155,37 @@ 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 (var rightResource in operation.GetSecondaryResources()) + { + ResourceType rightResourceType = _resourceGraph.GetResourceType(rightResource.GetType()); + + if (rightResourceType.IsVersioned) + { + string? requestVersion = rightResource.GetVersion(); + + if (requestVersion == null) + { + string? trackedVersion = _versionTracker.GetVersion(rightResourceType, rightResource.StringId!); + rightResource.SetVersion(trackedVersion); + } + } + } + } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs new file mode 100644 index 0000000000..b5a41b4f38 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Linq; +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 _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 (var relationship in _targetedFields.Relationships) + { + if (relationship.RightType.IsVersioned) + { + CaptureVersionsInRelationship(resource, relationship); + } + } + } + } + + private void CaptureVersionsInRelationship(IIdentifiable resource, RelationshipAttribute relationship) + { + object? afterRightValue = relationship.GetValue(resource); + ICollection afterRightResources = CollectionConverter.ExtractResources(afterRightValue); + + foreach (var 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}"; + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 8bc921af78..1407e9b4b8 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -278,6 +278,7 @@ private void AddOperationsLayer() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(); } public void Dispose() diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index b81c9bacd4..4146d5fec8 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -48,6 +48,11 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType); + /// + /// Builds a query that retrieves the primary resource, along with the subset of versioned targeted relationships, after a create/update/delete request. + /// + QueryLayer ComposeForGetVersionsAfterWrite(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); + /// /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. /// diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index d1a55551de..7e6d605293 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -401,6 +401,45 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType return primaryLayer; } + public QueryLayer ComposeForGetVersionsAfterWrite(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) + { + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + IImmutableSet includeElements = _targetedFields.Relationships + .Where(relationship => relationship.RightType.IsVersioned) + .Select(relationship => new IncludeElementExpression(relationship)) + .ToImmutableHashSet(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + + QueryLayer primaryLayer = new(primaryResourceType) + { + Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty, + Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, null) + }; + + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + primaryLayer.Projection = new Dictionary + { + [primaryIdAttribute] = null + }; + + foreach (var include in includeElements) + { + primaryLayer.Projection.Add(include.Relationship, null); + } + } + + return primaryLayer; + } + /// public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 4dc9127146..8389c11e50 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; @@ -35,11 +36,12 @@ public class JsonApiResourceService : IResourceService> _traceWriter; private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IVersionTracker _versionTracker; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) { ArgumentGuard.NotNull(repositoryAccessor, nameof(repositoryAccessor)); ArgumentGuard.NotNull(queryLayerComposer, nameof(queryLayerComposer)); @@ -48,6 +50,7 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(resourceChangeTracker, nameof(resourceChangeTracker)); + ArgumentGuard.NotNull(versionTracker, nameof(versionTracker)); ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); _repositoryAccessor = repositoryAccessor; @@ -56,6 +59,7 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ _options = options; _request = request; _resourceChangeTracker = resourceChangeTracker; + _versionTracker = versionTracker; _resourceDefinitionAccessor = resourceDefinitionAccessor; _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -234,7 +238,8 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa throw; } - TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource resourceFromDatabase = + await GetPrimaryResourceAfterWriteAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); @@ -413,7 +418,7 @@ protected async Task AssertRightResourcesExistAsync(object? rightValue, Cancella throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource afterResourceFromDatabase = await GetPrimaryResourceAfterWriteAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); @@ -451,6 +456,11 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa AssertIsNotResourceVersionMismatch(exception); throw; } + + if (_versionTracker.RequiresVersionTracking()) + { + await GetPrimaryResourceAfterWriteAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + } } /// @@ -527,6 +537,24 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSele return primaryResources.SingleOrDefault(); } + private async Task GetPrimaryResourceAfterWriteAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + if (_versionTracker.RequiresVersionTracking()) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetVersionsAfterWrite(id, _request.PrimaryResourceType, fieldSelection); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + TResource? primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + _versionTracker.CaptureVersions(_request.PrimaryResourceType, primaryResource); + return primaryResource; + } + + return await GetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); + } + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); diff --git a/test/DiscoveryTests/PrivateResourceService.cs b/test/DiscoveryTests/PrivateResourceService.cs index 47df356881..34ac457a36 100644 --- a/test/DiscoveryTests/PrivateResourceService.cs +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -14,8 +15,9 @@ public sealed class PrivateResourceService : JsonApiResourceService resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, resourceDefinitionAccessor) { } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 668cf7d66f..1953472042 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -36,6 +37,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs index ce66e0575f..541c1abaf0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -20,8 +21,9 @@ public sealed class ConsumerArticleService : JsonApiResourceService resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, resourceDefinitionAccessor) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs index 2d3086a236..390a8377bb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -23,8 +24,9 @@ public class MultiTenantResourceService : JsonApiResourceService public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, resourceDefinitionAccessor) { _tenantProvider = tenantProvider; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs new file mode 100644 index 0000000000..c5bc2e3c48 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs @@ -0,0 +1,275 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency +{ + public sealed class OptimisticConcurrencyOperationsTests + : IClassFixture, ConcurrencyDbContext>> + { + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public OptimisticConcurrencyOperationsTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact(Skip = "Does not work, requires investigation.")] + public async Task Tracks_versions_over_various_operations() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + existingPage.Footer = _fakers.PageFooter.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + string newImagePath1 = _fakers.WebImage.Generate().Path; + string newImagePath2 = _fakers.WebImage.Generate().Path; + string newImageDescription = _fakers.WebImage.Generate().Description!; + string newParagraphText = _fakers.Paragraph.Generate().Text; + int newBlockColumnCount = _fakers.TextBlock.Generate().ColumnCount; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + }); + + const string imageLid1 = "image-1"; + const string imageLid2 = "image-2"; + const string paragraphLid = "para-1"; + const string blockLid = "block-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + // create resource + new + { + op = "add", + data = new + { + type = "webImages", + lid = imageLid1, + attributes = new + { + path = newImagePath1 + } + } + }, + new + { + op = "add", + data = new + { + type = "webImages", + lid = imageLid2, + attributes = new + { + path = newImagePath2 + } + } + }, + new + { + op = "add", + data = new + { + type = "paragraphs", + lid = paragraphLid, + attributes = new + { + text = newParagraphText + }, + relationships = new + { + topImage = new + { + data = new + { + type = "webImages", + lid = imageLid1 + } + } + } + } + }, + new + { + op = "add", + data = new + { + type = "textBlocks", + lid = blockLid, + attributes = new + { + columnCount = newBlockColumnCount + }, + relationships = new + { + paragraphs = new + { + data = new[] + { + new + { + type = "paragraphs", + lid = paragraphLid + } + } + } + } + } + }, + // update resource + new + { + op = "update", + data = new + { + type = "webImages", + lid = imageLid1, + attributes = new + { + description = newImageDescription + } + } + }, + new + { + op = "update", + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + }, + content = new + { + data = new[] + { + new + { + type = "textBlocks", + lid = blockLid + } + } + } + } + } + }, + // delete resource + new + { + op = "remove", + @ref = new + { + type = "webImages", + lid = imageLid1 + } + }, + // set relationship + // Fix: the next operation fails, because the previous delete operation updated Paragraph, which we didn't track. + new + { + op = "update", + @ref = new + { + type = "paragraphs", + lid = paragraphLid, + relationship = "topImage" + }, + data = new + { + type = "webImages", + lid = imageLid2 + } + } + /* + new + { + op = "update", + @ref = new + { + type = "paragraphs", + lid = paragraphLid, + relationship = "usedIn" + }, + data = new[] + { + new + { + type = "textBlocks", + lid = blockLid + } + } + }, + new + { + op = "update", + data = new + { + type = "paragraphs", + lid = paragraphLid, + attributes = new + { + text = newParagraphText + }, + relationships = new + { + usedIn = new + { + data = Array.Empty() + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "textBlocks", + lid = blockLid + } + }*/ + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 5da32576b6..b1825351d7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -26,8 +27,9 @@ public class SoftDeletionAwareResourceService : JsonApiResourceS public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, resourceDefinitionAccessor) { _systemClock = systemClock;