diff --git a/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs new file mode 100644 index 0000000000..25c7b4e2c5 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs @@ -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); +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6524252abf..0f06f2e6fc 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -15,6 +15,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; @@ -22,12 +23,13 @@ public class OperationsProcessor : IOperationsProcessor 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, 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)); @@ -36,6 +38,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _operationProcessorAccessor = operationProcessorAccessor; _operationsTransactionFactory = operationsTransactionFactory; _localIdTracker = localIdTracker; + _versionTracker = versionTracker; _resourceGraph = resourceGraph; _request = request; _targetedFields = targetedFields; @@ -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) @@ -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); + } + } + } + } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs new file mode 100644 index 0000000000..4230f8a515 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs @@ -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 _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 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}"; + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 7cd4307ad1..09e864be16 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -275,6 +275,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 a9d99c3b13..1d00c42207 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -47,6 +47,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 40af882044..ab8ea6ff77 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -394,6 +394,46 @@ 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) + { + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryLayer.ResourceType); + primarySelectors.IncludeAttribute(primaryIdAttribute); + + foreach (IncludeElementExpression include in includeElements) + { + primarySelectors.IncludeRelationship(include.Relationship, null); + } + + primaryLayer.Selection = primarySelection; + } + + return primaryLayer; + } + /// public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1bfd4c8a4a..e936e5c870 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -2,6 +2,7 @@ using System.Net; using System.Runtime.CompilerServices; using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; @@ -30,11 +31,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) + IVersionTracker versionTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) { ArgumentGuard.NotNull(repositoryAccessor, nameof(repositoryAccessor)); ArgumentGuard.NotNull(queryLayerComposer, nameof(queryLayerComposer)); @@ -43,6 +45,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; @@ -51,6 +54,7 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ _options = options; _request = request; _resourceChangeTracker = resourceChangeTracker; + _versionTracker = versionTracker; _resourceDefinitionAccessor = resourceDefinitionAccessor; _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -226,7 +230,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); @@ -477,7 +482,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource afterResourceFromDatabase = await GetPrimaryResourceAfterWriteAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); @@ -522,6 +527,11 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa AssertIsNotResourceVersionMismatch(exception); throw; } + + if (_versionTracker.RequiresVersionTracking()) + { + await GetPrimaryResourceAfterWriteAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + } } /// @@ -614,6 +624,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 6d289eafb1..9e68e4bd67 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, resourceDefinitionAccessor) + 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 3396aed54b..70a4ac881d 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 335bf7e9fb..bb0fa8e34a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -17,8 +18,9 @@ public sealed class ConsumerArticleService : JsonApiResourceService resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) + 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 b46a8133d0..27b55020f9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -19,8 +20,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, resourceDefinitionAccessor) + 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..62a169d66b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs @@ -0,0 +1,271 @@ +using System.Net; +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.ShouldHaveStatusCode(HttpStatusCode.OK); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 299a40bad8..7436836b5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -21,8 +22,10 @@ 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, resourceDefinitionAccessor) + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, + resourceDefinitionAccessor) { _systemClock = systemClock; _targetedFields = targetedFields;