From 104ca5ed03053b2b4928515b2f1e4a1e9239bafd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 11 Aug 2021 17:11:37 +0200 Subject: [PATCH 1/3] Corrected terminology: The attributes from the incoming request, not the requested set of attribute. --- .../Resources/IResourceChangeTracker.cs | 2 +- .../Resources/ResourceChangeTracker.cs | 12 ++++++------ .../Services/JsonApiResourceService.cs | 4 ++-- .../NeverSameResourceChangeTracker.cs | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) 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/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/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0c3d9c521a..621b47f02f 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); @@ -334,7 +334,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); 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) { } From 1179d86d4877b32507af1217300831c8c2abbd8e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 11 Aug 2021 18:05:58 +0200 Subject: [PATCH 2/3] Fix failing tests when EF Core change tracker is purged more often (see next commit) --- .../Archiving/TelevisionFakers.cs | 9 +++-- ...reateResourceWithClientGeneratedIdTests.cs | 3 +- .../AtomicOperations/OperationsFakers.cs | 6 ++-- .../IntegrationTests/Logging/AuditFakers.cs | 3 +- .../QueryStrings/QueryStringFakers.cs | 6 ++-- .../ReadWrite/ReadWriteFakers.cs | 3 +- .../DefaultBehaviorFakers.cs | 3 +- .../InjectionFakers.cs | 3 +- .../Serialization/SerializationFakers.cs | 11 ++---- test/TestBuildingBlocks/DateTimeExtensions.cs | 34 +++++++++++++++++++ 10 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 test/TestBuildingBlocks/DateTimeExtensions.cs 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..58f7049f13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -105,7 +105,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/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/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/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/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/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); + } + } +} From cde395489ef4f6b4b29f32f8ad10c220a7d04a64 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 11 Aug 2021 15:27:51 +0200 Subject: [PATCH 3/3] Optimized DELETE Relationship endpoints (remove from to-many relationship) Before we'd fetch the full left resource including attributes, along with the full relationship (all related resources with all of their attributes). This commit changes that to only fetch the left ID and the subset of right IDs to be removed, then makes the EF Core change tracker believe the full relationship was loaded, so it can perform change detection and generate SQL for the diff. Replaced PlaceholderResourceCollector usage with resetting the change tracker on CreateResource/UpdateResource and failure in SaveChanges. This fixes broken JSON:API change tracking, where a side effect in database during save goes unnoticed. The refetch-after-save would execute the query, but did not refresh tracked entities. This is by design, see https://stackoverflow.com/questions/46205114/how-to-refresh-an-entity-framework-core-dbcontext. Resetting the full change tracker made this bug surface. --- src/JsonApiDotNetCore/CollectionExtensions.cs | 11 + .../JsonApiApplicationBuilder.cs | 11 +- .../EntityFrameworkCoreRepository.cs | 112 ++++++---- .../PlaceholderResourceCollector.cs | 85 -------- .../Resources/IResourceDefinition.cs | 15 +- .../Resources/OperationContainer.cs | 6 +- .../Building/IncludedResourceObjectBuilder.cs | 2 +- .../Services/JsonApiResourceService.cs | 21 +- ...reateResourceWithClientGeneratedIdTests.cs | 11 +- ...mplicitlyChangingTextLanguageDefinition.cs | 37 ++++ .../Meta/AtomicResponseMetaTests.cs | 3 + .../Meta/TextLanguageMetaDefinition.cs | 8 +- .../AtomicOperations/TextLanguage.cs | 8 +- .../Resources/AtomicUpdateResourceTests.cs | 12 +- ...y.cs => CarCompositeKeyAwareRepository.cs} | 22 +- .../CompositeKeys/CompositeKeyTests.cs | 3 +- .../FireForgetTests.Group.cs | 1 - .../OutboxTests.Group.cs | 1 - .../ReadWrite/Creating/CreateResourceTests.cs | 8 +- ...reateResourceWithClientGeneratedIdTests.cs | 13 +- .../ImplicitlyChangingWorkItemDefinition.cs | 36 ++++ ...plicitlyChangingWorkItemGroupDefinition.cs | 37 ++++ .../RemoveFromToManyRelationshipTests.cs | 194 ++++++++++++++++++ .../ReplaceToManyRelationshipTests.cs | 8 +- .../Updating/Resources/UpdateResourceTests.cs | 37 ++-- .../Resources/UpdateToOneRelationshipTests.cs | 16 +- .../IntegrationTests/ReadWrite/WorkItem.cs | 6 +- .../ReadWrite/WorkItemGroup.cs | 2 +- 28 files changed, 520 insertions(+), 206 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/{CarRepository.cs => CarCompositeKeyAwareRepository.cs} (53%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemGroupDefinition.cs 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/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/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 621b47f02f..9b659a4cdf 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -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) @@ -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/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 58f7049f13..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); }); } 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/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/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/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/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; }