diff --git a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs index 6e93519dae..3de20cb7fd 100644 --- a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs +++ b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs @@ -12,6 +12,8 @@ namespace Benchmarks.Tools; /// internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor { + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { return existingIncludes; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index c020653775..d206bd8b17 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -150,12 +150,17 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + IEntityType entityType = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - foreach (IProperty entityProperty in entityProperties) + foreach (IProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo!); + var propertySelector = new PropertySelector(property.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); + } + + foreach (INavigation navigation in entityType.GetNavigations().Where(navigation => navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty())) + { + var propertySelector = new PropertySelector(navigation.PropertyInfo!); IncludeWritableProperty(propertySelector, propertySelectors); } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 6f9c0cbb0d..66abfafbe0 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -151,7 +151,24 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) protected virtual IQueryable GetAll() { - return _dbContext.Set(); + IQueryable source = _dbContext.Set(); + + return GetTrackingBehavior() switch + { + QueryTrackingBehavior.NoTrackingWithIdentityResolution => source.AsNoTrackingWithIdentityResolution(), + QueryTrackingBehavior.NoTracking => source.AsNoTracking(), + QueryTrackingBehavior.TrackAll => source.AsTracking(), + _ => source + }; + } + + protected virtual QueryTrackingBehavior? GetTrackingBehavior() + { + // EF Core rejects the way we project sparse fieldsets when owned entities are involved, unless the query is explicitly + // marked as non-tracked (see https://github.com/dotnet/EntityFramework.Docs/issues/2205#issuecomment-1542914439). +#pragma warning disable CS0618 + return _resourceDefinitionAccessor.IsReadOnlyRequest ? QueryTrackingBehavior.NoTrackingWithIdentityResolution : null; +#pragma warning restore CS0618 } /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 55f32ead40..df0061a5aa 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -11,6 +11,15 @@ namespace JsonApiDotNetCore.Resources; /// public interface IResourceDefinitionAccessor { + /// + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] + bool IsReadOnlyRequest { get; } + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 79b48c99eb..6b7ac6625b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -15,6 +15,16 @@ public class ResourceDefinitionAccessor : IResourceDefinitionAccessor private readonly IResourceGraph _resourceGraph; private readonly IServiceProvider _serviceProvider; + /// + public bool IsReadOnlyRequest + { + get + { + var request = _serviceProvider.GetRequiredService(); + return request.IsReadOnly; + } + } + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) { ArgumentGuard.NotNull(resourceGraph); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs new file mode 100644 index 0000000000..97017706e0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class Address +{ + public string Street { get; set; } = null!; + public string? ZipCode { get; set; } + public string City { get; set; } = null!; + public string Country { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs index 4580d21c52..118e0c9df5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs @@ -11,6 +11,9 @@ public sealed class MeetingAttendee : Identifiable [Attr] public string DisplayName { get; set; } = null!; + [Attr] + public Address HomeAddress { get; set; } = null!; + [HasOne] public Meeting? Meeting { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs index 885c3b950a..32e093214c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; +// @formatter:wrap_chained_method_calls chop_always + namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; [UsedImplicitly(ImplicitUseTargetFlags.Members)] @@ -14,4 +16,12 @@ public SerializationDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .OwnsOne(meetingAttendee => meetingAttendee.HomeAddress); + + base.OnModelCreating(builder); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs index a7dded542c..6f327deef4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -29,7 +29,14 @@ internal sealed class SerializationFakers : FakerContainer private readonly Lazy> _lazyMeetingAttendeeFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(attendee => attendee.DisplayName, faker => faker.Random.Utf16String())); + .RuleFor(attendee => attendee.DisplayName, faker => faker.Random.Utf16String()) + .RuleFor(attendee => attendee.HomeAddress, faker => new Address + { + Street = faker.Address.StreetAddress(), + ZipCode = faker.Address.ZipCode(), + City = faker.Address.City(), + Country = faker.Address.Country() + })); public Faker Meeting => _lazyMeetingFaker.Value; public Faker MeetingAttendee => _lazyMeetingAttendeeFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index 63eeabb4c9..efe7f1353c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -142,7 +142,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", + ""homeAddress"": { + ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", + ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", + ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", + ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -191,7 +197,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + attendee.StringId + @""", ""attributes"": { - ""displayName"": """ + attendee.DisplayName + @""" + ""displayName"": """ + attendee.DisplayName + @""", + ""homeAddress"": { + ""street"": """ + attendee.HomeAddress.Street + @""", + ""zipCode"": """ + attendee.HomeAddress.ZipCode + @""", + ""city"": """ + attendee.HomeAddress.City + @""", + ""country"": """ + attendee.HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -465,7 +477,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", + ""homeAddress"": { + ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", + ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", + ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", + ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -704,7 +722,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + existingAttendee.StringId + @""", ""attributes"": { - ""displayName"": """ + existingAttendee.DisplayName + @""" + ""displayName"": """ + existingAttendee.DisplayName + @""", + ""homeAddress"": { + ""street"": """ + existingAttendee.HomeAddress.Street + @""", + ""zipCode"": """ + existingAttendee.HomeAddress.ZipCode + @""", + ""city"": """ + existingAttendee.HomeAddress.City + @""", + ""country"": """ + existingAttendee.HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs index 8cd108deae..6126b9d744 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs @@ -9,6 +9,8 @@ namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response; internal sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor { + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { return existingIncludes;