diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index 41596c7b22..ca69755c1c 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -70,5 +70,16 @@ public static bool DictionaryEqual(this IReadOnlyDictionary(this ICollection source, IEnumerable itemsToAdd) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(itemsToAdd, nameof(itemsToAdd)); + + foreach (T item in itemsToAdd) + { + source.Add(item); + } + } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 129b30d11c..6419e0327c 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -284,15 +284,20 @@ private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder { foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) { -#pragma warning disable EF1001 // Internal EF Core API usage. - if (entityType is not EntityType { IsImplicitlyCreatedJoinEntityType: true }) -#pragma warning restore EF1001 // Internal EF Core API usage. + if (!IsImplicitManyToManyJoinEntity(entityType)) { builder.Add(entityType.ClrType); } } } + private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) + { +#pragma warning disable EF1001 // Internal EF Core API usage. + return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true }; +#pragma warning restore EF1001 // Internal EF Core API usage. + } + public void Dispose() { _intermediateProvider.Dispose(); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a86d002b51..c6ba15f10d 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -182,8 +182,6 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Create resource"); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { object rightValue = relationship.GetValue(resourceFromRequest); @@ -191,7 +189,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r object rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource, cancellationToken); - await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, collector, cancellationToken); + await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken); } foreach (AttrAttribute attribute in _targetedFields.Attributes) @@ -207,6 +205,8 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r await SaveChangesAsync(cancellationToken); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + _dbContext.ResetChangeTracker(); } private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightValue, @@ -254,8 +254,6 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Update resource"); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { object rightValue = relationship.GetValue(resourceFromRequest); @@ -265,7 +263,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightValueEvaluated); - await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, collector, cancellationToken); + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } foreach (AttrAttribute attribute in _targetedFields.Attributes) @@ -278,18 +276,21 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r await SaveChangesAsync(cancellationToken); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + _dbContext.ResetChangeTracker(); } protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue) { - bool relationshipIsRequired = false; - - if (relationship is not HasManyAttribute { IsManyToMany: true }) + if (relationship is HasManyAttribute { IsManyToMany: true }) { - INavigation navigation = TryGetNavigation(relationship); - relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; + // Many-to-many relationships cannot be required. + return; } + INavigation navigation = TryGetNavigation(relationship); + bool relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; + bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship ? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue) : rightValue == null; @@ -326,14 +327,13 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. - // If so, we'll reuse the tracked resource instead of a placeholder resource. - var emptyResource = _resourceFactory.CreateInstance(); - emptyResource.Id = id; + // If so, we'll reuse the tracked resource instead of this placeholder resource. + var placeholderResource = _resourceFactory.CreateInstance(); + placeholderResource.Id = id; - await _resourceDefinitionAccessor.OnWritingAsync(emptyResource, WriteOperationKind.DeleteResource, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - TResource resource = collector.CreateForId(id); + var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceContext().Relationships) { @@ -341,16 +341,16 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke // entities into memory is required for successfully executing the selected deletion behavior. if (RequiresLoadOfRelationshipForDeletion(relationship)) { - NavigationEntry navigation = GetNavigationEntry(resource, relationship); + NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship); await navigation.LoadAsync(cancellationToken); } } - _dbContext.Remove(resource); + _dbContext.Remove(resourceTracked); await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resource, WriteOperationKind.DeleteResource, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken); } private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) @@ -413,8 +413,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object ri AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightValueEvaluated); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, collector, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); @@ -442,16 +441,18 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet(leftId); + var leftPlaceholderResource = _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; - await UpdateRelationshipAsync(relationship, leftResource, rightResourceIds, collector, cancellationToken); + var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource); - await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.AddToRelationship, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIds, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.AddToRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); } } @@ -470,35 +471,62 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + HashSet rightResourceIdsToRemove = rightResourceIds.ToHashSet(IdentifiableComparer.Instance); - await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIds, cancellationToken); + await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIdsToRemove, cancellationToken); - if (rightResourceIds.Any()) + if (rightResourceIdsToRemove.Any()) { + var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource); + + // Make EF Core believe any additional resources added from ResourceDefinition already exist in database. + IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray(); + object rightValueStored = relationship.GetValue(leftResource); - HashSet rightResourceIdsToStore = - _collectionConverter.ExtractResources(rightValueStored).ToHashSet(IdentifiableComparer.Instance); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - rightResourceIdsToStore.ExceptWith(rightResourceIds); + IIdentifiable[] rightResourceIdsStored = _collectionConverter + .ExtractResources(rightValueStored) + .Concat(extraResourceIdsToRemove) + .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); + relationship.SetValue(leftResource, rightValueStored); + + MarkRelationshipAsLoaded(leftResource, relationship); - AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightResourceIdsToStore); + HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); + rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, leftResource, rightResourceIdsToStore, collector, cancellationToken); + AssertIsNotClearingRequiredRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); } } + private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttribute relationship) + { + EntityEntry leftEntry = _dbContext.Entry(leftResource); + CollectionEntry rightCollectionEntry = leftEntry.Collection(relationship.Property.Name); + rightCollectionEntry.IsLoaded = true; + } + protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign, - PlaceholderResourceCollector collector, CancellationToken cancellationToken) + CancellationToken cancellationToken) { - object trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType, collector); + object trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) { @@ -511,7 +539,7 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, relationship.SetValue(leftResource, trackedValueToAssign); } - private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType, PlaceholderResourceCollector collector) + private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType) { if (rightValue == null) { @@ -519,7 +547,7 @@ private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type } ICollection rightResources = _collectionConverter.ExtractResources(rightValue); - IIdentifiable[] rightResourcesTracked = rightResources.Select(collector.CaptureExisting).ToArray(); + IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray(); return rightValue is IEnumerable ? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) @@ -551,6 +579,8 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken); } + _dbContext.ResetChangeTracker(); + throw new DataStoreUpdateException(exception); } } diff --git a/src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs b/src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs deleted file mode 100644 index ed8c7d4a49..0000000000 --- a/src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// Creates placeholder resource instances (with only their ID property set), which are added to the Entity Framework Core change tracker so they can be - /// used in relationship updates without fetching the resource. On disposal, the created placeholders are detached, leaving the change tracker in a clean - /// state for reuse. - /// - [PublicAPI] - public sealed class PlaceholderResourceCollector : IDisposable - { - private readonly IResourceFactory _resourceFactory; - private readonly DbContext _dbContext; - private readonly List _resources = new(); - - public PlaceholderResourceCollector(IResourceFactory resourceFactory, DbContext dbContext) - { - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - - _resourceFactory = resourceFactory; - _dbContext = dbContext; - } - - /// - /// Creates a new placeholder resource, assigns the specified ID, adds it to the change tracker in state and - /// registers it for detachment. - /// - public TResource CreateForId(TId id) - where TResource : IIdentifiable - { - var placeholderResource = _resourceFactory.CreateInstance(); - placeholderResource.Id = id; - - return CaptureExisting(placeholderResource); - } - - /// - /// Takes an existing placeholder resource, adds it to the change tracker in state and registers it for detachment. - /// - public TResource CaptureExisting(TResource placeholderResource) - where TResource : IIdentifiable - { - var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); - - if (ReferenceEquals(resourceTracked, placeholderResource)) - { - _resources.Add(resourceTracked); - } - - return resourceTracked; - } - - /// - /// Detaches the collected placeholder resources from the change tracker. - /// - public void Dispose() - { - Detach(_resources); - - _resources.Clear(); - } - - private void Detach(IEnumerable resources) - { - foreach (object resource in resources) - { - try - { - _dbContext.Entry(resource).State = EntityState.Detached; - } - catch (InvalidOperationException) - { - // If SaveChanges() threw due to a foreign key constraint violation, its exception is rethrown here. - // We swallow this exception, to allow the originating error to propagate. - } - } - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index 79eca2ed6a..df16dc603f 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -15,7 +15,7 @@ public interface IResourceChangeTracker /// /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. /// - void SetRequestedAttributeValues(TResource resource); + void SetRequestAttributeValues(TResource resource); /// /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index afe27b9f9e..4fae023853 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -149,9 +149,9 @@ public interface IResourceDefinition /// /// /// Identifies the logical write operation for which this method was called. Possible values: , - /// , and - /// . Note this intentionally excludes and - /// , because for those endpoints no resource is retrieved upfront. + /// and . Note this intentionally excludes + /// , and + /// , because for those endpoints no resource is retrieved upfront. /// /// /// Propagates notification that request handling should be canceled. @@ -243,7 +243,8 @@ Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelati /// /// /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . + /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of + /// resources to be removed. /// /// /// The to-many relationship being removed from. @@ -273,9 +274,9 @@ Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasM /// /// /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with - /// the changes from the incoming request. Exception: In case is or - /// , this is an empty object with only the property set, because - /// for those endpoints no resource is retrieved upfront. + /// the changes from the incoming request. Exception: In case is , + /// or , this is an empty object with only + /// the property set, because for those endpoints no resource is retrieved upfront. /// /// /// Identifies the logical write operation for which this method was called. Possible values: , diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index 11e6636ccf..d5350a34dc 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -57,11 +57,9 @@ public ISet GetSecondaryResources() private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) { object rightValue = relationship.GetValue(Resource); + ICollection rightResources = CollectionConverter.ExtractResources(rightValue); - foreach (IIdentifiable rightResource in CollectionConverter.ExtractResources(rightValue)) - { - secondaryResources.Add(rightResource); - } + secondaryResources.AddRange(rightResources); } } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 1dfc5a09a7..dc1aeafb91 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -16,7 +16,7 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker _initiallyStoredAttributeValues; - private IDictionary _requestedAttributeValues; + private IDictionary _requestAttributeValues; private IDictionary _finallyStoredAttributeValues; public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider resourceContextProvider, ITargetedFields targetedFields) @@ -40,11 +40,11 @@ public void SetInitiallyStoredAttributeValues(TResource resource) } /// - public void SetRequestedAttributeValues(TResource resource) + public void SetRequestAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - _requestedAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); + _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); } /// @@ -75,12 +75,12 @@ public bool HasImplicitChanges() { foreach (string key in _initiallyStoredAttributeValues.Keys) { - if (_requestedAttributeValues.ContainsKey(key)) + if (_requestAttributeValues.ContainsKey(key)) { - string requestedValue = _requestedAttributeValues[key]; + string requestValue = _requestAttributeValues[key]; string actualValue = _finallyStoredAttributeValues[key]; - if (requestedValue != actualValue) + if (requestValue != actualValue) { return true; } diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 451a3f0f7e..4ba4a3f9f1 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -67,7 +67,7 @@ public IList Build() private void UpdateRelationships(ResourceObject resourceObject) { - foreach (string relationshipName in resourceObject.Relationships.Keys.ToArray()) + foreach (string relationshipName in resourceObject.Relationships.Keys) { ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(resourceObject.Type); RelationshipAttribute relationship = resourceContext.Relationships.Single(rel => rel.PublicName == relationshipName); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0c3d9c521a..9b659a4cdf 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -169,7 +169,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); TResource resourceFromRequest = resource; - _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resource.Id, cancellationToken); @@ -289,6 +289,17 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + { + TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); + + object rightValue = _request.Relationship.GetValue(leftResource); + ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); + + rightResourceIds.ExceptWith(existingRightResourceIds); + } + + private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); IReadOnlyCollection leftResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); @@ -296,10 +307,7 @@ private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttrib TResource leftResource = leftResources.FirstOrDefault(); AssertPrimaryResourceExists(leftResource); - object rightValue = _request.Relationship.GetValue(leftResource); - ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); - - rightResourceIds.ExceptWith(existingRightResourceIds); + return leftResource; } protected async Task AssertRightResourcesExistAsync(object rightValue, CancellationToken cancellationToken) @@ -334,7 +342,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); TResource resourceFromRequest = resource; - _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); @@ -429,10 +437,9 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); AssertHasRelationship(_request.Relationship, relationshipName); + var hasManyRelationship = (HasManyAttribute)_request.Relationship; - TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); - - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.RemoveFromRelationship, cancellationToken); + TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs index aa51ac4c11..962abff6ab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs @@ -23,14 +23,17 @@ internal sealed class TelevisionFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) - .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset()) - .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset())); + .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds()) + .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset() + .TruncateToWholeMilliseconds())); private readonly Lazy> _lazyBroadcastCommentFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) - .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset())); + .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index cf0a53bf70..7a956a1b48 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -29,6 +29,11 @@ public AtomicCreateResourceWithClientGeneratedIdTests( // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; } @@ -72,15 +77,15 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.Results.Should().HaveCount(1); responseDocument.Results[0].SingleData.Should().NotBeNull(); responseDocument.Results[0].SingleData.Type.Should().Be("textLanguages"); - responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newLanguage.IsoCode); - responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("concurrencyToken"); + responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newLanguage.IsoCode + ImplicitlyChangingTextLanguageDefinition.Suffix); + responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("isRightToLeft"); responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguage.Id); - languageInDatabase.IsoCode.Should().Be(newLanguage.IsoCode); + languageInDatabase.IsoCode.Should().Be(newLanguage.IsoCode + ImplicitlyChangingTextLanguageDefinition.Suffix); }); } @@ -105,7 +110,8 @@ public async Task Can_create_resource_with_client_generated_string_ID_having_no_ attributes = new { title = newTrack.Title, - lengthInSeconds = newTrack.LengthInSeconds + lengthInSeconds = newTrack.LengthInSeconds, + releasedAt = newTrack.ReleasedAt } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs new file mode 100644 index 0000000000..e2f7a50840 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + /// + /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. + /// + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class ImplicitlyChangingTextLanguageDefinition : JsonApiResourceDefinition + { + internal const string Suffix = " (changed)"; + + private readonly OperationsDbContext _dbContext; + + public ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, OperationsDbContext dbContext) + : base(resourceGraph) + { + _dbContext = dbContext; + } + + public override async Task OnWriteSucceededAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation is not WriteOperationKind.DeleteResource) + { + string statement = "Update \"TextLanguages\" SET \"IsoCode\" = '" + resource.IsoCode + Suffix + "' WHERE \"Id\" = '" + resource.Id + "'"; + await _dbContext.Database.ExecuteSqlRawAsync(statement, cancellationToken); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index c784b63e36..b533e730e5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; @@ -28,6 +29,8 @@ public AtomicResponseMetaTests(ExampleIntegrationTestContext { + services.AddResourceDefinition(); + services.AddSingleton(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs index c1c2be73b4..23a48fb89a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -1,20 +1,18 @@ -using System; using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TextLanguageMetaDefinition : JsonApiResourceDefinition + public sealed class TextLanguageMetaDefinition : ImplicitlyChangingTextLanguageDefinition { internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; private readonly ResourceDefinitionHitCounter _hitCounter; - public TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + public TextLanguageMetaDefinition(IResourceGraph resourceGraph, OperationsDbContext dbContext, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, dbContext) { _hitCounter = hitCounter; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs index 7589a07792..c85486ca78 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -30,7 +30,8 @@ internal sealed class OperationsFakers : FakerContainer .RuleFor(musicTrack => musicTrack.Title, faker => faker.Lorem.Word()) .RuleFor(musicTrack => musicTrack.LengthInSeconds, faker => faker.Random.Decimal(3 * 60, 5 * 60)) .RuleFor(musicTrack => musicTrack.Genre, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset())); + .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); private readonly Lazy> _lazyLyricFaker = new(() => new Faker() @@ -47,7 +48,8 @@ internal sealed class OperationsFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) - .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset())); + .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); private readonly Lazy> _lazyRecordCompanyFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs index be25c89b7d..8256b4e1b2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,13 +12,8 @@ public sealed class TextLanguage : Identifiable [Attr] public string IsoCode { get; set; } - [NotMapped] [Attr(Capabilities = AttrCapabilities.None)] - public Guid ConcurrencyToken - { - get => Guid.NewGuid(); - set => _ = value; - } + public bool IsRightToLeft { get; set; } [HasMany] public ICollection Lyrics { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 5ee958edb6..ee3f22e54d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; using JsonApiDotNetCoreExampleTests.Startups; @@ -28,6 +29,11 @@ public AtomicUpdateResourceTests(ExampleIntegrationTestContext(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); } [Fact] @@ -424,15 +430,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Results.Should().HaveCount(1); responseDocument.Results[0].SingleData.Should().NotBeNull(); responseDocument.Results[0].SingleData.Type.Should().Be("textLanguages"); - responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newIsoCode); - responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("concurrencyToken"); + responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newIsoCode + ImplicitlyChangingTextLanguageDefinition.Suffix); + responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("isRightToLeft"); responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(existingLanguage.Id); - languageInDatabase.IsoCode.Should().Be(newIsoCode); + languageInDatabase.IsoCode.Should().Be(newIsoCode + ImplicitlyChangingTextLanguageDefinition.Suffix); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs similarity index 53% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index af9690cdf4..191cab3bd4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -11,18 +11,20 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class CarRepository : EntityFrameworkCoreRepository + public class CarCompositeKeyAwareRepository : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable { private readonly CarExpressionRewriter _writer; - public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { _writer = new CarExpressionRewriter(resourceGraph); } - protected override IQueryable ApplyQueryLayer(QueryLayer layer) + protected override IQueryable ApplyQueryLayer(QueryLayer layer) { RecursiveRewriteFilterInLayer(layer); @@ -50,4 +52,16 @@ private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) } } } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class CarCompositeKeyAwareRepository : CarCompositeKeyAwareRepository, IResourceRepository + where TResource : class, IIdentifiable + { + public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + { + } + } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 3aa9f082fa..5ae72052bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -28,7 +28,8 @@ public CompositeKeyTests(ExampleIntegrationTestContext { - services.AddResourceRepository(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs index 60e1534d69..988456a211 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs @@ -13,7 +13,8 @@ internal sealed class AuditFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) - .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset())); + .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); public Faker AuditEntry => _lazyAuditEntryFaker.Value; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs index 1c22f1834a..f5c3f7d255 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -573,7 +573,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync), (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index cc71a19f6d..a2ed4c303f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -603,7 +603,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync), (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) }, options => options.WithStrictOrdering()); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index 8817b66f1c..abf4650265 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -31,7 +31,8 @@ internal sealed class QueryStringFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) - .RuleFor(comment => comment.CreatedAt, faker => faker.Date.Past())); + .RuleFor(comment => comment.CreatedAt, faker => faker.Date.Past() + .TruncateToWholeMilliseconds())); private readonly Lazy> _lazyWebAccountFaker = new(() => new Faker() @@ -57,7 +58,8 @@ internal sealed class QueryStringFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(appointment => appointment.Title, faker => faker.Random.Word()) - .RuleFor(appointment => appointment.StartTime, faker => faker.Date.FutureOffset()) + .RuleFor(appointment => appointment.StartTime, faker => faker.Date.FutureOffset() + .TruncateToWholeMilliseconds()) .RuleFor(appointment => appointment.EndTime, (faker, appointment) => appointment.StartTime.AddHours(faker.Random.Double(1, 4)))); public Faker Blog => _lazyBlogFaker.Value; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index ffcfabadcd..2ca5e9ee1d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -524,7 +524,7 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() type = "workItems", attributes = new { - concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + isImportant = true } } }; @@ -542,7 +542,7 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); - error.Detail.Should().StartWith("Setting the initial value of 'concurrencyToken' is not allowed. - Request body:"); + error.Detail.Should().StartWith("Setting the initial value of 'isImportant' is not allowed. - Request body:"); } [Fact] @@ -556,7 +556,7 @@ public async Task Cannot_create_resource_with_readonly_attribute() type = "workItemGroups", attributes = new { - concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + isDeprecated = false } } }; @@ -574,7 +574,7 @@ public async Task Cannot_create_resource_with_readonly_attribute() Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); + error.Detail.Should().StartWith("Attribute 'isDeprecated' is read-only. - Request body:"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 24de94277a..9e06bd9598 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -27,6 +27,11 @@ public CreateResourceWithClientGeneratedIdTests(ExampleIntegrationTestContext(); testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; } @@ -62,14 +67,14 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); responseDocument.SingleData.Id.Should().Be(newGroup.StringId); - responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name + ImplicitlyChangingWorkItemGroupDefinition.Suffix); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItemGroup groupInDatabase = await dbContext.Groups.FirstWithIdAsync(newGroup.Id); - groupInDatabase.Name.Should().Be(newGroup.Name); + groupInDatabase.Name.Should().Be(newGroup.Name + ImplicitlyChangingWorkItemGroupDefinition.Suffix); }); PropertyInfo property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); @@ -108,14 +113,14 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Type.Should().Be("workItemGroups"); responseDocument.SingleData.Id.Should().Be(newGroup.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name + ImplicitlyChangingWorkItemGroupDefinition.Suffix); responseDocument.SingleData.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItemGroup groupInDatabase = await dbContext.Groups.FirstWithIdAsync(newGroup.Id); - groupInDatabase.Name.Should().Be(newGroup.Name); + groupInDatabase.Name.Should().Be(newGroup.Name + ImplicitlyChangingWorkItemGroupDefinition.Suffix); }); PropertyInfo property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs new file mode 100644 index 0000000000..ba58036605 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + /// + /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. + /// + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class ImplicitlyChangingWorkItemDefinition : JsonApiResourceDefinition + { + internal const string Suffix = " (changed)"; + + private readonly ReadWriteDbContext _dbContext; + + public ImplicitlyChangingWorkItemDefinition(IResourceGraph resourceGraph, ReadWriteDbContext dbContext) + : base(resourceGraph) + { + _dbContext = dbContext; + } + + public override async Task OnWriteSucceededAsync(WorkItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation is not WriteOperationKind.DeleteResource) + { + string statement = "Update \"WorkItems\" SET \"Description\" = '" + resource.Description + Suffix + "' WHERE \"Id\" = '" + resource.Id + "'"; + await _dbContext.Database.ExecuteSqlRawAsync(statement, cancellationToken); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemGroupDefinition.cs new file mode 100644 index 0000000000..13118246dc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemGroupDefinition.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + /// + /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. + /// + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class ImplicitlyChangingWorkItemGroupDefinition : JsonApiResourceDefinition + { + internal const string Suffix = " (changed)"; + + private readonly ReadWriteDbContext _dbContext; + + public ImplicitlyChangingWorkItemGroupDefinition(IResourceGraph resourceGraph, ReadWriteDbContext dbContext) + : base(resourceGraph) + { + _dbContext = dbContext; + } + + public override async Task OnWriteSucceededAsync(WorkItemGroup resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation is not WriteOperationKind.DeleteResource) + { + string statement = "Update \"Groups\" SET \"Name\" = '" + resource.Name + Suffix + "' WHERE \"Id\" = '" + resource.Id + "'"; + await _dbContext.Database.ExecuteSqlRawAsync(statement, cancellationToken); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs index 4e019f71dc..a8e40bc388 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs @@ -13,7 +13,8 @@ internal sealed class ReadWriteFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(workItem => workItem.Description, faker => faker.Lorem.Sentence()) - .RuleFor(workItem => workItem.DueAt, faker => faker.Date.Future()) + .RuleFor(workItem => workItem.DueAt, faker => faker.Date.Future() + .TruncateToWholeMilliseconds()) .RuleFor(workItem => workItem.Priority, faker => faker.PickRandom())); private readonly Lazy> _lazyWorkTagFaker = new(() => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 80bc5ecd50..1452e4adf0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -3,11 +3,18 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -24,6 +31,14 @@ public RemoveFromToManyRelationshipTests(ExampleIntegrationTestContext(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton, RemoveExtraFromWorkItemDefinition>(); + }); + + var workItemDefinition = (RemoveExtraFromWorkItemDefinition)testContext.Factory.Services.GetRequiredService>(); + workItemDefinition.Reset(); } [Fact] @@ -118,6 +133,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_extra_removals_from_resource_definition() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var workItemDefinition = (RemoveExtraFromWorkItemDefinition)_testContext.Factory.Services.GetRequiredService>(); + workItemDefinition.ExtraSubscribersIdsToRemove.Add(existingWorkItem.Subscribers.ElementAt(2).Id); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + workItemDefinition.PreloadedSubscribers.Should().HaveCount(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + + List userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + userAccountsInDatabase.Should().HaveCount(3); + }); + } + [Fact] public async Task Can_remove_from_ManyToMany_relationship_with_unassigned_existing_resource() { @@ -173,6 +241,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Can_remove_from_ManyToMany_relationship_with_extra_removals_from_resource_definition() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Tags = _fakers.WorkTag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var workItemDefinition = (RemoveExtraFromWorkItemDefinition)_testContext.Factory.Services.GetRequiredService>(); + workItemDefinition.ExtraTagIdsToRemove.Add(existingWorkItem.Tags.ElementAt(2).Id); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.Tags.ElementAt(1).StringId + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + workItemDefinition.PreloadedTags.Should().HaveCount(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(existingWorkItem.Id); + + workItemInDatabase.Tags.Should().HaveCount(1); + workItemInDatabase.Tags.Single().Id.Should().Be(existingWorkItem.Tags.ElementAt(0).Id); + + List tagsInDatabase = await dbContext.WorkTags.ToListAsync(); + tagsInDatabase.Should().HaveCount(3); + }); + } + [Fact] public async Task Cannot_remove_for_missing_request_body() { @@ -837,5 +958,78 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.RelatedTo.Should().BeEmpty(); }); } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class RemoveExtraFromWorkItemDefinition : JsonApiResourceDefinition + { + // Enables to verify that not the full relationship was loaded upfront. + public ISet PreloadedSubscribers { get; } = new HashSet(IdentifiableComparer.Instance); + public ISet PreloadedTags { get; } = new HashSet(IdentifiableComparer.Instance); + + // Enables to verify that adding extra IDs for removal from ResourceDefinition works correctly. + public ISet ExtraSubscribersIdsToRemove { get; } = new HashSet(); + public ISet ExtraTagIdsToRemove { get; } = new HashSet(); + + public RemoveExtraFromWorkItemDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public void Reset() + { + PreloadedSubscribers.Clear(); + PreloadedTags.Clear(); + + ExtraSubscribersIdsToRemove.Clear(); + ExtraTagIdsToRemove.Clear(); + } + + public override Task OnRemoveFromRelationshipAsync(WorkItem workItem, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(WorkItem.Subscribers)) + { + RemoveFromSubscribers(workItem, rightResourceIds); + } + else if (hasManyRelationship.Property.Name == nameof(WorkItem.Tags)) + { + RemoveFromTags(workItem, rightResourceIds); + } + + return Task.CompletedTask; + } + + private void RemoveFromSubscribers(WorkItem workItem, ISet rightResourceIds) + { + if (!workItem.Subscribers.IsNullOrEmpty()) + { + PreloadedSubscribers.AddRange(workItem.Subscribers); + } + + foreach (long subscriberId in ExtraSubscribersIdsToRemove) + { + rightResourceIds.Add(new UserAccount + { + Id = subscriberId + }); + } + } + + private void RemoveFromTags(WorkItem workItem, ISet rightResourceIds) + { + if (!workItem.Tags.IsNullOrEmpty()) + { + PreloadedTags.AddRange(workItem.Tags); + } + + foreach (int tagId in ExtraTagIdsToRemove) + { + rightResourceIds.Add(new WorkTag + { + Id = tagId + }); + } + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index f6d555c4cf..491c9c326c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; @@ -24,6 +25,11 @@ public ReplaceToManyRelationshipTests(ExampleIntegrationTestContext(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); } [Fact] @@ -300,7 +306,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(1); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 93ae540392..b6b0c3d160 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; @@ -28,6 +29,12 @@ public UpdateResourceTests(ExampleIntegrationTestContext(); testContext.UseController(); testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); + }); } [Fact] @@ -202,7 +209,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); responseDocument.SingleData.Id.Should().Be(existingGroup.StringId); - responseDocument.SingleData.Attributes["name"].Should().Be(newName); + responseDocument.SingleData.Attributes["name"].Should().Be(newName + ImplicitlyChangingWorkItemGroupDefinition.Suffix); responseDocument.SingleData.Attributes["isPublic"].Should().Be(existingGroup.IsPublic); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); @@ -210,7 +217,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItemGroup groupInDatabase = await dbContext.Groups.FirstWithIdAsync(existingGroup.Id); - groupInDatabase.Name.Should().Be(newName); + groupInDatabase.Name.Should().Be(newName + ImplicitlyChangingWorkItemGroupDefinition.Suffix); groupInDatabase.IsPublic.Should().Be(existingGroup.IsPublic); }); @@ -349,17 +356,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); - responseDocument.SingleData.Attributes.Should().ContainKey("concurrencyToken"); + responseDocument.SingleData.Attributes["isImportant"].Should().Be(false); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.Description.Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); workItemInDatabase.DueAt.Should().BeNull(); workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); }); @@ -404,7 +411,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(2); - responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); responseDocument.SingleData.Relationships.Should().BeNull(); @@ -412,7 +419,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.Description.Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); workItemInDatabase.DueAt.Should().BeNull(); workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); }); @@ -459,7 +466,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(2); - responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); responseDocument.SingleData.Relationships.Should().HaveCount(1); responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); @@ -476,7 +483,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.Description.Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); workItemInDatabase.DueAt.Should().BeNull(); workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); }); @@ -817,7 +824,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, attributes = new { - concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + isImportant = true } } }; @@ -835,7 +842,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); - error.Detail.Should().StartWith("Changing the value of 'concurrencyToken' is not allowed. - Request body:"); + error.Detail.Should().StartWith("Changing the value of 'isImportant' is not allowed. - Request body:"); } [Fact] @@ -858,7 +865,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingWorkItem.StringId, attributes = new { - concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + isDeprecated = true } } }; @@ -876,7 +883,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); + error.Detail.Should().StartWith("Attribute 'isDeprecated' is read-only. - Request body:"); } [Fact] @@ -1067,7 +1074,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -1084,7 +1091,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.Description.Should().Be(newDescription + ImplicitlyChangingWorkItemDefinition.Suffix); workItemInDatabase.Assignee.Should().NotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index bc9665edcb..55eb2b638c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; @@ -24,6 +25,11 @@ public UpdateToOneRelationshipTests(ExampleIntegrationTestContext(); testContext.UseController(); testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); } [Fact] @@ -111,11 +117,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = "/workItemGroups/" + existingGroup.StringId; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.SingleData.Should().NotBeNull(); + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -341,7 +347,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description + ImplicitlyChangingWorkItemDefinition.Suffix); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(1); @@ -407,7 +413,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description + ImplicitlyChangingWorkItemDefinition.Suffix); responseDocument.SingleData.Relationships.Should().HaveCount(1); responseDocument.SingleData.Relationships["assignee"].SingleData.Id.Should().Be(existingUserAccount.StringId); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs index 747ec55721..c35612f7d7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs @@ -21,10 +21,10 @@ public sealed class WorkItem : Identifiable [NotMapped] [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] - public Guid ConcurrencyToken + public bool IsImportant { - get => Guid.NewGuid(); - set => _ = value; + get => Priority == WorkItemPriority.High; + set => Priority = value ? WorkItemPriority.High : throw new NotSupportedException(); } [HasOne] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs index 740c96ad22..3b33c5710d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs @@ -18,7 +18,7 @@ public sealed class WorkItemGroup : Identifiable [NotMapped] [Attr] - public Guid ConcurrencyToken => Guid.NewGuid(); + public bool IsDeprecated => Name != null && Name.StartsWith("DEPRECATED:", StringComparison.OrdinalIgnoreCase); [HasOne] public RgbColor Color { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs index 2aff7fbefb..6c8716067f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs @@ -23,7 +23,8 @@ internal sealed class DefaultBehaviorFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(shipment => shipment.TrackAndTraceCode, faker => faker.Commerce.Ean13()) - .RuleFor(shipment => shipment.ShippedAt, faker => faker.Date.Past())); + .RuleFor(shipment => shipment.ShippedAt, faker => faker.Date.Past() + .TruncateToWholeMilliseconds())); public Faker Orders => _orderFaker.Value; public Faker Customers => _customerFaker.Value; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs index 8fdcdd7c23..75d17e4d12 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs @@ -35,7 +35,8 @@ public InjectionFakers(IServiceProvider serviceProvider) new Faker() .UseSeed(GetFakerSeed()) .CustomInstantiator(_ => new GiftCertificate(ResolveDbContext())) - .RuleFor(giftCertificate => giftCertificate.IssueDate, faker => faker.Date.PastOffset())); + .RuleFor(giftCertificate => giftCertificate.IssueDate, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); } private InjectionDbContext ResolveDbContext() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs index bfeccadb16..95a692f5d9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -21,7 +21,8 @@ internal sealed class SerializationFakers : FakerContainer new Faker() .UseSeed(GetFakerSeed()) .RuleFor(meeting => meeting.Title, faker => faker.Lorem.Word()) - .RuleFor(meeting => meeting.StartTime, faker => TruncateToWholeMilliseconds(faker.Date.FutureOffset())) + .RuleFor(meeting => meeting.StartTime, faker => faker.Date.FutureOffset() + .TruncateToWholeMilliseconds()) .RuleFor(meeting => meeting.Duration, faker => faker.PickRandom(MeetingDurations)) .RuleFor(meeting => meeting.Latitude, faker => faker.Address.Latitude()) .RuleFor(meeting => meeting.Longitude, faker => faker.Address.Longitude())); @@ -33,13 +34,5 @@ internal sealed class SerializationFakers : FakerContainer public Faker Meeting => _lazyMeetingFaker.Value; public Faker MeetingAttendee => _lazyMeetingAttendeeFaker.Value; - - private static DateTimeOffset TruncateToWholeMilliseconds(DateTimeOffset value) - { - long ticksToSubtract = value.DateTime.Ticks % TimeSpan.TicksPerMillisecond; - long ticksInWholeMilliseconds = value.DateTime.Ticks - ticksToSubtract; - - return new DateTimeOffset(new DateTime(ticksInWholeMilliseconds), value.Offset); - } } } diff --git a/test/TestBuildingBlocks/DateTimeExtensions.cs b/test/TestBuildingBlocks/DateTimeExtensions.cs new file mode 100644 index 0000000000..e6678995da --- /dev/null +++ b/test/TestBuildingBlocks/DateTimeExtensions.cs @@ -0,0 +1,34 @@ +using System; + +namespace TestBuildingBlocks +{ + public static class DateTimeExtensions + { + // The milliseconds precision in DateTime/DateTimeOffset values that fakers produce is higher + // than what PostgreSQL can store. This results in our resource change tracker to detect + // that the time stored in database differs from the time in the request body. + // While that's technically correct, we don't want such side effects influencing our tests everywhere. + + public static DateTimeOffset TruncateToWholeMilliseconds(this DateTimeOffset value) + { + DateTime dateTime = TruncateToWholeMilliseconds(value.DateTime); + + // PostgreSQL is unable to store the timezone in the database, so it always uses the local zone. + // See https://www.npgsql.org/doc/types/datetime.html. + // We're taking the local zone here, to prevent our change tracker to detect that the time + // stored in database differs from the time in the request body. While that's technically + // correct, we don't want such side effects influencing our tests everywhere. + TimeSpan offset = TimeZoneInfo.Local.GetUtcOffset(dateTime); + + return new DateTimeOffset(dateTime, offset); + } + + public static DateTime TruncateToWholeMilliseconds(this DateTime value) + { + long ticksToSubtract = value.Ticks % TimeSpan.TicksPerMillisecond; + long ticksInWholeMilliseconds = value.Ticks - ticksToSubtract; + + return new DateTime(ticksInWholeMilliseconds, value.Kind); + } + } +} diff --git a/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs b/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs index a98fd5a8e2..16ae8e1454 100644 --- a/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs +++ b/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs @@ -12,7 +12,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) { } - public void SetRequestedAttributeValues(TResource resource) + public void SetRequestAttributeValues(TResource resource) { }