From 4c0eafc336c35fd7f37ad0950c312c1121f9960c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:27:12 +0100 Subject: [PATCH 01/28] Corrected test to detect all fields are retrieved --- .../IntegrationTests/QueryStrings/Blog.cs | 2 ++ .../QueryStrings/SparseFieldSets/SparseFieldSetTests.cs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index 58a4546ef5..1ad9f4b526 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -17,6 +17,8 @@ public sealed class Blog : Identifiable [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); + public bool IsPublished { get; set; } + [HasMany] public IList Posts { get; set; } = new List(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 141c0addb4..292f7edfd8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -749,6 +749,7 @@ public async Task Retrieves_all_properties_when_fieldset_contains_readonly_attri store.Clear(); Blog blog = _fakers.Blog.Generate(); + blog.IsPublished = true; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -771,7 +772,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Relationships.Should().BeNull(); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); - blogCaptured.ShowAdvertisements.Should().Be(blogCaptured.ShowAdvertisements); + blogCaptured.ShowAdvertisements.Should().Be(blog.ShowAdvertisements); + blogCaptured.IsPublished.Should().Be(blog.IsPublished); blogCaptured.Title.Should().Be(blog.Title); } @@ -817,7 +819,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); - postCaptured.Url.Should().Be(postCaptured.Url); + postCaptured.Url.Should().Be(post.Url); } [Fact] From 3000ba9e6b046f8894acddbfee8eecf1fa47d788 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:29:00 +0100 Subject: [PATCH 02/28] Removed unused injected parameters --- .../IntegrationTests/HostingInIIS/HostingStartup.cs | 6 ++---- test/TestBuildingBlocks/TestableStartup.cs | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs index 24546c2062..a22dfe5954 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs @@ -1,9 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; @@ -20,10 +18,10 @@ protected override void SetJsonApiOptions(JsonApiOptions options) options.IncludeTotalResourceCount = true; } - public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + public override void Configure(IApplicationBuilder app) { app.UsePathBase("/iis-application-virtual-directory"); - base.Configure(app, environment, loggerFactory); + base.Configure(app); } } diff --git a/test/TestBuildingBlocks/TestableStartup.cs b/test/TestBuildingBlocks/TestableStartup.cs index a54a317fed..687b9aa406 100644 --- a/test/TestBuildingBlocks/TestableStartup.cs +++ b/test/TestBuildingBlocks/TestableStartup.cs @@ -1,9 +1,7 @@ using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace TestBuildingBlocks; @@ -24,7 +22,7 @@ protected virtual void SetJsonApiOptions(JsonApiOptions options) options.SerializerOptions.WriteIndented = true; } - public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + public virtual void Configure(IApplicationBuilder app) { app.UseRouting(); app.UseJsonApi(); From f1163380695c2478c89057986e97b6250a59b109 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:29:45 +0100 Subject: [PATCH 03/28] Renamed parameter to match base class --- test/TestBuildingBlocks/HttpResponseMessageExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs index 200bbfa308..37716fafd1 100644 --- a/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs +++ b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs @@ -17,8 +17,8 @@ public sealed class HttpResponseMessageAssertions : ReferenceTypeAssertions "response"; - public HttpResponseMessageAssertions(HttpResponseMessage instance) - : base(instance) + public HttpResponseMessageAssertions(HttpResponseMessage subject) + : base(subject) { } From affef06ead1ed8b216096ea558201b6b00ecc804 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:37:28 +0100 Subject: [PATCH 04/28] TestContext: Only dispose factory when created --- .../DuplicateResourceControllerTests.cs | 5 ----- .../UnknownResourceControllerTests.cs | 5 ----- test/TestBuildingBlocks/IntegrationTestContext.cs | 9 ++++++--- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs index 7fec2a877e..b130523588 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs @@ -22,9 +22,4 @@ public void Fails_at_startup_when_multiple_controllers_exist_for_same_resource_t // Assert action.Should().ThrowExactly().WithMessage("Multiple controllers found for resource type 'knownResources'."); } - - public override void Dispose() - { - // Prevents crash when test cleanup tries to access lazily constructed Factory. - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs index 1cfb3cc34c..55d09dc2fe 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs @@ -22,9 +22,4 @@ public void Fails_at_startup_when_using_controller_for_resource_type_that_is_not action.Should().ThrowExactly().WithMessage($"Controller '{typeof(UnknownResourcesController)}' " + $"depends on resource type '{typeof(UnknownResource)}', which does not exist in the resource graph."); } - - public override void Dispose() - { - // Prevents crash when test cleanup tries to access lazily constructed Factory. - } } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index f25eb29070..8ccbc14079 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -103,11 +103,14 @@ private WebApplicationFactory CreateFactory() return factoryWithConfiguredContentRoot; } - public virtual void Dispose() + public void Dispose() { - RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()).Wait(); + if (_lazyFactory.IsValueCreated) + { + RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()).Wait(); - Factory.Dispose(); + _lazyFactory.Value.Dispose(); + } } public void ConfigureLogging(Action loggingConfiguration) From 88e207402b465a28a4e130ed2e20e94c8a4d06b6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:42:38 +0100 Subject: [PATCH 05/28] Removed external JsonTimeSpanConverter (TimeSpan support was added in .NET 6) --- .../QueryStrings/Filtering/FilterDataTypeTests.cs | 5 ++--- .../IntegrationTests/QueryStrings/LabelColor.cs | 2 +- .../IntegrationTests/ReadWrite/WorkItemPriority.cs | 2 +- .../ResourceDefinitions/Reading/StarKind.cs | 2 +- .../IntegrationTests/Serialization/SerializationTests.cs | 6 ------ test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj | 1 - 6 files changed, 5 insertions(+), 13 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index 49a6c99ad6..cfe90a295d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -26,10 +26,9 @@ public FilterDataTypeTests(IntegrationTestContext(); options.EnableLegacyFilterNotation = false; - if (!options.SerializerOptions.Converters.Any(converter => converter is JsonStringEnumMemberConverter)) + if (!options.SerializerOptions.Converters.Any(converter => converter is JsonStringEnumConverter)) { - options.SerializerOptions.Converters.Add(new JsonStringEnumMemberConverter()); - options.SerializerOptions.Converters.Add(new JsonTimeSpanConverter()); + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LabelColor.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LabelColor.cs index 4402ecdb05..ce7419c3d6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LabelColor.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LabelColor.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[JsonConverter(typeof(JsonStringEnumMemberConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum LabelColor { Red, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemPriority.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemPriority.cs index 5a09a274d8..52710fbfbf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemPriority.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemPriority.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[JsonConverter(typeof(JsonStringEnumMemberConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum WorkItemPriority { Low, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarKind.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarKind.cs index 5e64c578e7..51918a8a3a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarKind.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarKind.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[JsonConverter(typeof(JsonStringEnumMemberConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum StarKind { Other, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index 85c93428a2..574b3ac622 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Net; -using System.Text.Json.Serialization; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -34,11 +33,6 @@ public SerializationTests(IntegrationTestContext converter is JsonTimeSpanConverter)) - { - options.SerializerOptions.Converters.Add(new JsonTimeSpanConverter()); - } } [Fact] diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index b68d9ae4a4..ddb7550e5e 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -17,7 +17,6 @@ - From b6870f597bc7ecd52a3307e6a6d77939d676d9bc Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:48:32 +0100 Subject: [PATCH 06/28] Add missing assertion comments --- .../Updating/Relationships/UpdateToOneRelationshipTests.cs | 3 +++ .../Updating/Resources/UpdateToOneRelationshipTests.cs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index 67ca5070e7..c7174ec225 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -168,6 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); @@ -222,6 +223,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); @@ -760,6 +762,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 3b0e69c5f1..8c1a5a4ee8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -114,6 +114,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); @@ -283,6 +284,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.Data.SingleValue.ShouldNotBeNull(); From b17f8f5731de7404c43bb194279a6bd14f75e190 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:54:26 +0100 Subject: [PATCH 07/28] Fixed: do not auto-register abstract base classes and interfaces --- src/JsonApiDotNetCore/Configuration/TypeLocator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 2a0369d940..2f004ffdf1 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -89,7 +89,7 @@ internal sealed class TypeLocator private static (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromType(Type nextType, Type unboundInterface, Type[] interfaceTypeArguments) { - if (!nextType.IsNested) + if (!nextType.IsNested && !nextType.IsAbstract && !nextType.IsInterface) { foreach (Type nextConstructedInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) { From 8a0657a350e165cbcaef0a7f5b199b5f22571432 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 20 Dec 2021 16:57:14 +0100 Subject: [PATCH 08/28] Removed Templates checkbox from PR template --- .github/PULL_REQUEST_TEMPLATE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ed1eea959b..3e23a87e27 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,4 +7,3 @@ Closes #{ISSUE_NUMBER} - [ ] Complies with our [contributing guidelines](./.github/CONTRIBUTING.md) - [ ] Adapted tests - [ ] Documentation updated -- [ ] Created issue to update [Templates](https://github.com/json-api-dotnet/Templates/issues/new): {ISSUE_NUMBER} From ab7aa7ed6e9114acac46e8d1e9c1a7202cdedbe7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 21 Dec 2021 10:14:42 +0100 Subject: [PATCH 09/28] Resource management --- .../NonJsonApiControllerTests.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index 9c3364f4c5..f3dbbefd63 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -23,10 +23,10 @@ public async Task Get_skips_middleware_and_formatters() // Arrange using var request = new HttpRequestMessage(HttpMethod.Get, "/NonJsonApi"); - HttpClient client = _testContext.Factory.CreateClient(); + using HttpClient client = _testContext.Factory.CreateClient(); // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + using HttpResponseMessage httpResponse = await client.SendAsync(request); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -52,10 +52,10 @@ public async Task Post_skips_middleware_and_formatters() } }; - HttpClient client = _testContext.Factory.CreateClient(); + using HttpClient client = _testContext.Factory.CreateClient(); // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + using HttpResponseMessage httpResponse = await client.SendAsync(request); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -72,10 +72,10 @@ public async Task Post_skips_error_handler() // Arrange using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); - HttpClient client = _testContext.Factory.CreateClient(); + using HttpClient client = _testContext.Factory.CreateClient(); // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + using HttpResponseMessage httpResponse = await client.SendAsync(request); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); @@ -101,10 +101,10 @@ public async Task Put_skips_middleware_and_formatters() } }; - HttpClient client = _testContext.Factory.CreateClient(); + using HttpClient client = _testContext.Factory.CreateClient(); // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + using HttpResponseMessage httpResponse = await client.SendAsync(request); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -121,10 +121,10 @@ public async Task Patch_skips_middleware_and_formatters() // Arrange using var request = new HttpRequestMessage(HttpMethod.Patch, "/NonJsonApi?name=Janice"); - HttpClient client = _testContext.Factory.CreateClient(); + using HttpClient client = _testContext.Factory.CreateClient(); // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + using HttpResponseMessage httpResponse = await client.SendAsync(request); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -141,10 +141,10 @@ public async Task Delete_skips_middleware_and_formatters() // Arrange using var request = new HttpRequestMessage(HttpMethod.Delete, "/NonJsonApi"); - HttpClient client = _testContext.Factory.CreateClient(); + using HttpClient client = _testContext.Factory.CreateClient(); // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + using HttpResponseMessage httpResponse = await client.SendAsync(request); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); From b13c4ff5c575c90e364d50ab126f3a5ab420ea2b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 21 Dec 2021 10:15:10 +0100 Subject: [PATCH 10/28] Corrected AddRange with single item --- .../Reading/ResourceDefinitionReadTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index d6b97d556a..efd1f577e6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -331,7 +331,7 @@ public async Task Filter_from_resource_definition_is_applied_on_secondary_endpoi await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Stars.AddRange(star); + dbContext.Stars.Add(star); await dbContext.SaveChangesAsync(); }); @@ -387,7 +387,7 @@ public async Task Filter_from_resource_definition_is_applied_on_relationship_end await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Stars.AddRange(star); + dbContext.Stars.Add(star); await dbContext.SaveChangesAsync(); }); From 368e694ab2b8d3b5c4a7c500b2554f2fb39ba1ab Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 15 Jan 2022 14:19:35 +0100 Subject: [PATCH 11/28] Added an example project that uses a different database per tenant. Note this only works when the database structure is identical for all tenants. Answer to question at https://gitter.im/json-api-dotnet-core/Lobby?at=61e00492f5a394780002705b. --- JsonApiDotNetCore.sln | 15 ++++ JsonApiDotNetCore.sln.DotSettings | 1 + .../Controllers/EmployeesController.cs | 15 ++++ .../Data/AppDbContext.cs | 80 +++++++++++++++++++ .../DatabasePerTenantExample.csproj | 16 ++++ .../Models/Employee.cs | 19 +++++ .../DatabasePerTenantExample/Program.cs | 58 ++++++++++++++ .../Properties/launchSettings.json | 30 +++++++ .../DatabasePerTenantExample/appsettings.json | 15 ++++ 9 files changed, 249 insertions(+) create mode 100644 src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs create mode 100644 src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs create mode 100644 src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj create mode 100644 src/Examples/DatabasePerTenantExample/Models/Employee.cs create mode 100644 src/Examples/DatabasePerTenantExample/Program.cs create mode 100644 src/Examples/DatabasePerTenantExample/Properties/launchSettings.json create mode 100644 src/Examples/DatabasePerTenantExample/appsettings.json diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 7237596aed..0a8ed12d2a 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -52,6 +52,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorTests", "tes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.Annotations", "src\JsonApiDotNetCore.Annotations\JsonApiDotNetCore.Annotations.csproj", "{83FF097C-C8C6-477B-9FAB-DF99B84978B5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", "src\Examples\DatabasePerTenantExample\DatabasePerTenantExample.csproj", "{60334658-BE51-43B3-9C4D-F2BBF56C89CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -266,6 +268,18 @@ Global {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x64.Build.0 = Release|Any CPU {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.ActiveCfg = Release|Any CPU {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -288,6 +302,7 @@ Global {87D066F9-3540-4AC7-A748-134900969EE5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 92cce56c47..c4f0d3d36d 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -631,6 +631,7 @@ $left$ = $right$; WARNING True True + True True True True diff --git a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs new file mode 100644 index 0000000000..5e17afab9b --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Controllers.Annotations; +using Microsoft.AspNetCore.Mvc; + +namespace DatabasePerTenantExample.Controllers; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class EmployeesController +{ +} + +[DisableRoutingConvention] +[Route("api/{tenantName}/employees")] +partial class EmployeesController +{ +} diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..c70fc8655f --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -0,0 +1,80 @@ +using System.Net; +using DatabasePerTenantExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; + +namespace DatabasePerTenantExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IConfiguration _configuration; + private string? _forcedTenantName; + + public DbSet Employees => Set(); + + public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + { + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + } + + public void SetTenantName(string tenantName) + { + _forcedTenantName = tenantName; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + string connectionString = GetConnectionString(); + optionsBuilder.UseNpgsql(connectionString); + } + + private string GetConnectionString() + { + string? tenantName = GetTenantName(); + string connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"]; + + if (connectionString == null) + { + throw GetErrorForInvalidTenant(tenantName); + } + + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + return connectionString.Replace("###", postgresPassword); + } + + private string? GetTenantName() + { + if (_forcedTenantName != null) + { + return _forcedTenantName; + } + + if (_httpContextAccessor.HttpContext != null) + { + string? tenantName = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["tenantName"]; + + if (tenantName == null) + { + throw GetErrorForInvalidTenant(null); + } + + return tenantName; + } + + return null; + } + + private static JsonApiException GetErrorForInvalidTenant(string? tenantName) + { + return new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Missing or invalid tenant in URL.", + Detail = $"Tenant '{tenantName}' does not exist." + }); + } +} diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj new file mode 100644 index 0000000000..b243e99ec2 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -0,0 +1,16 @@ + + + $(TargetFrameworkName) + + + + + + + + + + + + diff --git a/src/Examples/DatabasePerTenantExample/Models/Employee.cs b/src/Examples/DatabasePerTenantExample/Models/Employee.cs new file mode 100644 index 0000000000..cc79449880 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Models/Employee.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DatabasePerTenantExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Employee : Identifiable +{ + [Attr] + public string FirstName { get; set; } = null!; + + [Attr] + public string LastName { get; set; } = null!; + + [Attr] + public string CompanyName { get; set; } = null!; +} diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs new file mode 100644 index 0000000000..b6f960831d --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -0,0 +1,58 @@ +using DatabasePerTenantExample.Data; +using DatabasePerTenantExample.Models; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddSingleton(); +builder.Services.AddDbContext(options => options.UseNpgsql()); + +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.SerializerOptions.WriteIndented = true; +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(null, app.Services); +await CreateDatabaseAsync("AdventureWorks", app.Services); +await CreateDatabaseAsync("Contoso", app.Services); + +app.Run(); + +static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + if (tenantName != null) + { + dbContext.SetTenantName(tenantName); + } + + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + + if (tenantName != null) + { + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); + + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json new file mode 100644 index 0000000000..1ab75296f7 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14147", + "sslPort": 44340 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/AdventureWorks/employees", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/AdventureWorks/employees", + "applicationUrl": "https://localhost:44347;http://localhost:14147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json new file mode 100644 index 0000000000..c065f66c64 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -0,0 +1,15 @@ +{ + "Data": { + "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###", + "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###", + "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "AllowedHosts": "*" +} From c214e195d8ee2d084c39690914b9b2f8c252c91a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 14 Feb 2022 13:35:43 +0100 Subject: [PATCH 12/28] Workaround for bug in EF Core 6.0.2 Pinned to v6.0.1 due to https://github.com/dotnet/efcore/issues/27436 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 36c9ede075..81503b5f37 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net6.0 6.0.* - 6.0.* + 6.0.1 6.0.* 4.* 2.* From bc5fbe0a0fa611220889320d1374eed2489916d8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Feb 2022 00:56:14 +0100 Subject: [PATCH 13/28] Added workaround for bug in EF Core 6.0.2 (#1139) * Added workaround for bug in EF Core 6.0.2 * Package updates * Downgrade Microsoft.CodeAnalysis to fix cibuild --- Directory.Build.props | 8 ++++---- src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs | 6 ++++++ test/TestBuildingBlocks/TestBuildingBlocks.csproj | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 81503b5f37..66d90258f9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,9 +2,9 @@ net6.0 6.0.* - 6.0.1 + 6.0.* 6.0.* - 4.* + 4.0.* 2.* 5.0.0 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset @@ -29,8 +29,8 @@ - 3.1.0 + 3.1.2 4.16.1 - 17.0.0 + 17.1.0 diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index aa7ed0d434..eda6374acf 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -102,6 +102,12 @@ public sealed class JsonApiOptions : IJsonApiOptions } }; + static JsonApiOptions() + { + // Bug workaround for https://github.com/dotnet/efcore/issues/27436 + AppContext.SetSwitch("Microsoft.EntityFrameworkCore.Issue26779", true); + } + public JsonApiOptions() { _lazySerializerReadOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions), LazyThreadSafetyMode.PublicationOnly); diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 2c8fdd1cd5..5b3d041185 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -10,7 +10,7 @@ - + From a622954d6ae7020b8b7c28bb6440920bee4f7521 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 2 Mar 2022 10:59:56 +0100 Subject: [PATCH 14/28] Bugfix: hide Self link in included resources when there's no registered controller for it. Before, it would use the controller URL from the current request in the Self link, which is wrong. --- .../Serialization/Response/LinkBuilder.cs | 8 ++++++++ .../IntegrationTests/Links/LinkInclusionTests.cs | 1 + .../UnitTests/Links/LinkInclusionTests.cs | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 38e63ea9cb..000c75bc80 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -255,6 +255,14 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); + + if (controllerName == null) + { + // When passing null to RenderLinkForAction, it uses the controller for the current endpoint. This is incorrect for + // included resources of a different resource type: it should hide their Self links when there's no controller for them. + return null; + } + IDictionary routeValues = GetRouteValues(resource.StringId!, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs index 128a19efb9..c23bed2b6b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs @@ -17,6 +17,7 @@ public LinkInclusionTests(IntegrationTestContext testContext.UseController(); testContext.UseController(); + testContext.UseController(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 6c0e9fe1a0..5effda921f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -386,7 +387,7 @@ public ResourceType GetResourceTypeForController(Type? controllerType) public string? GetControllerNameForResourceType(ResourceType? resourceType) { - return null; + return resourceType == null ? null : $"{resourceType.PublicName.Pascalize()}Controller"; } } From 473d53b76faa19f69556a5ff0168a631808fc0db Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 2 Mar 2022 11:28:08 +0100 Subject: [PATCH 15/28] Added test --- .../Links/LinkInclusionIncludeTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs new file mode 100644 index 0000000000..8c8b8e4ec4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class LinkInclusionIncludeTests : IClassFixture, LinksDbContext>> +{ + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new(); + + public LinkInclusionIncludeTests(IntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Hides_Self_link_in_included_resources_for_unregistered_controllers() + { + // Arrange + PhotoLocation location = _fakers.PhotoLocation.Generate(); + location.Photo = _fakers.Photo.Generate(); + location.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoLocations.Add(location); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/photoLocations/{location.StringId}?include=photo,album"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included.Should().ContainSingle(resource => resource.Type == "photos").Subject.With(resource => + { + resource.Links.Should().BeNull(); + + resource.Relationships.ShouldContainKey("location").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + }); + }); + + responseDocument.Included.Should().ContainSingle(resource => resource.Type == "photoAlbums").Subject.With(resource => + { + resource.Links.Should().BeNull(); + }); + } +} From ab67c860a525c4d8269abba70bb5693bcc2106dd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 8 Mar 2022 13:10:28 +0100 Subject: [PATCH 16/28] Fixed: also hide links for missing controllers on deeply nested relationships from includes --- .../Serialization/Response/LinkBuilder.cs | 15 +++++++-------- .../Links/LinkInclusionIncludeTests.cs | 18 +++++++++++------- ...CreateResourceWithToOneRelationshipTests.cs | 1 + .../ReadWrite/Fetching/FetchResourceTests.cs | 1 + .../ReplaceToManyRelationshipTests.cs | 1 + .../Resources/UpdateToOneRelationshipTests.cs | 1 + .../IntegrationTests/ReadWrite/WorkTag.cs | 1 + .../UnitTests/Links/LinkInclusionTests.cs | 5 ++++- 8 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 000c75bc80..0bad02066b 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -255,14 +255,6 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); - - if (controllerName == null) - { - // When passing null to RenderLinkForAction, it uses the controller for the current endpoint. This is incorrect for - // included resources of a different resource type: it should hide their Self links when there's no controller for them. - return null; - } - IDictionary routeValues = GetRouteValues(resource.StringId!, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); @@ -320,6 +312,13 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) { + if (controllerName == null) + { + // When passing null to LinkGenerator, it uses the controller for the current endpoint. This is incorrect for + // included resources of a different resource type: it should hide its links when there's no controller for them. + return null; + } + return _options.UseRelativeLinks ? _linkGenerator.GetPathByAction(HttpContext, actionName, controllerName, routeValues) : _linkGenerator.GetUriByAction(HttpContext, actionName, controllerName, routeValues); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs index 8c8b8e4ec4..f93ea357d4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs @@ -19,7 +19,7 @@ public LinkInclusionIncludeTests(IntegrationTestContext // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + }); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included.Should().ContainSingle(resource => resource.Type == "photos").Subject.With(resource => { resource.Links.Should().BeNull(); - - resource.Relationships.ShouldContainKey("location").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - }); + resource.Relationships.Should().BeNull(); }); responseDocument.Included.Should().ContainSingle(resource => resource.Type == "photoAlbums").Subject.With(resource => { resource.Links.Should().BeNull(); + resource.Relationships.Should().BeNull(); }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index 377edfc6a3..b70e53eaf0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -22,6 +22,7 @@ public CreateResourceWithToOneRelationshipTests(IntegrationTestContext(); testContext.UseController(); testContext.UseController(); + testContext.UseController(); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index 5f2e13d3e4..ac9d9fb872 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -17,6 +17,7 @@ public FetchResourceTests(IntegrationTestContext(); testContext.UseController(); + testContext.UseController(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index 158ad4cc10..0c44fb6995 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -19,6 +19,7 @@ public ReplaceToManyRelationshipTests(IntegrationTestContext(); + testContext.UseController(); testContext.ConfigureServicesAfterStartup(services => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 8c1a5a4ee8..1d0b689713 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -20,6 +20,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext(); testContext.UseController(); testContext.UseController(); + testContext.UseController(); testContext.ConfigureServicesAfterStartup(services => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs index 585690fb77..f2408bd022 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ReadWrite")] public sealed class WorkTag : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 5effda921f..81bd4a6ef6 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -69,7 +69,10 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso PrimaryId = "1", IsCollection = true, Kind = EndpointKind.Relationship, - Relationship = new HasOneAttribute() + Relationship = new HasOneAttribute + { + LeftType = exampleResourceType + } }; var paginationContext = new PaginationContext From 978a311596fe94784bf99ec95b47e032c31a60f2 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 21 Mar 2022 01:44:59 +0100 Subject: [PATCH 17/28] Replaced references to Error in documentation, which was renamed to ErrorObject (#1143) --- docs/usage/errors.md | 12 ++++++------ docs/usage/extensibility/resource-definitions.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/usage/errors.md b/docs/usage/errors.md index 67ce6fde17..955612dc67 100644 --- a/docs/usage/errors.md +++ b/docs/usage/errors.md @@ -1,13 +1,13 @@ # Errors -Errors returned will contain only the properties that are set on the `Error` class. Custom fields can be added through `Error.Meta`. -You can create a custom error by throwing a `JsonApiException` (which accepts an `Error` instance), or returning an `Error` instance from an `ActionResult` in a controller. -Please keep in mind that JSON:API requires Title to be a generic message, while Detail should contain information about the specific problem occurence. +Errors returned will contain only the properties that are set on the `ErrorObject` class. Custom fields can be added through `ErrorObject.Meta`. +You can create a custom error by throwing a `JsonApiException` (which accepts an `ErrorObject` instance), or returning an `ErrorObject` instance from an `ActionResult` in a controller. +Please keep in mind that JSON:API requires `Title` to be a generic message, while `Detail` should contain information about the specific problem occurence. From a controller method: ```c# -return Conflict(new Error(HttpStatusCode.Conflict) +return Conflict(new ErrorObject(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." @@ -17,7 +17,7 @@ return Conflict(new Error(HttpStatusCode.Conflict) From other code: ```c# -throw new JsonApiException(new Error(HttpStatusCode.Conflict) +throw new JsonApiException(new ErrorObject(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." @@ -75,7 +75,7 @@ public class CustomExceptionHandler : ExceptionHandler { return new[] { - new Error(HttpStatusCode.Conflict) + new ErrorObject(HttpStatusCode.Conflict) { Title = "Product is temporarily available.", Detail = $"Product {productOutOfStock.ProductId} " + diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index 8696811e16..af4f8a27c5 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -200,7 +200,7 @@ public class EmployeeDefinition : JsonApiResourceDefinition if (existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Employee.Manager))) { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { Title = "Including the manager of employees is not permitted." }); From 5b99f22bd5177f2910b83e80639586d27a43d0f5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 2 Apr 2022 00:04:01 +0200 Subject: [PATCH 18/28] Refreshed expired NuGet API key --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 4a39f6c4e3..61feec2ab8 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -82,14 +82,14 @@ for: - provider: NuGet skip_symbols: false api_key: - secure: OBYPCgp3WCuwkDRMuZ9a4QcBdTja/lqlUwZ+Yl5VHqooSJRVTYKP5y15XK0fuHsZ + secure: S9fkLwmhi7w+DGouXYqYq/1PGocnYo8UBUKwv+BGpWHnzE6yHZEYth3j/XJ9Ydsa on: branch: master appveyor_repo_tag: true - provider: NuGet skip_symbols: false api_key: - secure: OBYPCgp3WCuwkDRMuZ9a4QcBdTja/lqlUwZ+Yl5VHqooSJRVTYKP5y15XK0fuHsZ + secure: S9fkLwmhi7w+DGouXYqYq/1PGocnYo8UBUKwv+BGpWHnzE6yHZEYth3j/XJ9Ydsa on: branch: /release\/.+/ appveyor_repo_tag: true From 388f94f876942663d1420c8d12017d347895cdc6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 4 Apr 2022 09:55:21 +0200 Subject: [PATCH 19/28] Added link to new video (from me) (#1147) * Added link to 2021 video, reorganized links * Fixed broken API references in documentation --- README.md | 17 +++++++++++------ docs/docfx.json | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 71e5f523e5..b640fbc5c0 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,17 @@ The ultimate goal of this library is to eliminate as much boilerplate as possibl These are some steps you can take to help you understand what this project is and how you can use it: -- [What is JSON:API and why should I use it?](https://nordicapis.com/the-benefits-of-using-json-api/) -- [The JSON:API specification](http://jsonapi.org/format/) -- Demo [Video](https://youtu.be/KAMuo6K7VcE), [Blog](https://dev.to/wunki/getting-started-5dkl) -- [Our documentation](https://www.jsonapi.net/) -- [Check out the example projects](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples) -- [Embercasts: Full Stack Ember with ASP.NET Core](https://www.embercasts.com/course/full-stack-ember-with-dotnet/watch/whats-in-this-course-cs) +### About +- [What is JSON:API and why should I use it?](https://nordicapis.com/the-benefits-of-using-json-api/) (blog, 2017) +- [Pragmatic JSON:API Design](https://www.youtube.com/watch?v=3jBJOga4e2Y) (video, 2017) +- [JSON:API and JsonApiDotNetCore](https://www.youtube.com/watch?v=79Oq0HOxyeI) (video, 2021) +- [JsonApiDotNetCore Release 4.0](https://dev.to/wunki/getting-started-5dkl) (blog, 2020) +- [JSON:API, .Net Core, EmberJS](https://youtu.be/KAMuo6K7VcE) (video, 2017) +- [Embercasts: Full Stack Ember with ASP.NET Core](https://www.embercasts.com/course/full-stack-ember-with-dotnet/watch/whats-in-this-course-cs) (paid course, 2017) + +### Official documentation +- [The JSON:API specification](https://jsonapi.org/format/1.1/) +- [JsonApiDotNetCore website](https://www.jsonapi.net/) - [Roadmap](ROADMAP.md) ## Related Projects diff --git a/docs/docfx.json b/docs/docfx.json index 98e83101a5..7fdafa0fe5 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -3,7 +3,7 @@ { "src": [ { - "files": [ "**/JsonApiDotNetCore.csproj" ], + "files": [ "**/JsonApiDotNetCore.csproj","**/JsonApiDotNetCore.Annotations.csproj" ], "src": "../" } ], From b48f02e5e2ef9b1550972d8c89c5951f66be8ef1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 4 Apr 2022 10:02:36 +0200 Subject: [PATCH 20/28] Resource inheritance (#1142) * Removed existing resource inheritance tests * Resource inheritance: return derived types * Resource inheritance: sparse fieldsets * Changed the expression tokenizer to recognize dots in field chains * Resource inheritance: derived includes * Resource inheritance: derived filters * Added ResourceFieldAttribute.Type and use it from QueryExpression.ToFullString() to provide improved debug info * Resource inheritance: sorting on derived fields * Added missing tests for GET abstract/concrete base primary types at secondary/relationship endpoints * Resource graph validation: fail if field from base type does not exist on derived type * Clarified error message on type mismatch in request body * Rename inheritance tests * Added extension method to obtain ClrType of a resource instance (we'll need this later) * Resource inheritance: write endpoints * Updated documentation * Added rewriter unit tests to improve code coverage * Exclude example project from code coverage * Review feedback * Update ROADMAP.md --- JsonApiDotNetCore.sln.DotSettings | 1 + ROADMAP.md | 2 +- .../ResourceSerializationBenchmarks.cs | 18 +- .../SerializationBenchmarkBase.cs | 4 +- docs/usage/reading/filtering.md | 27 + docs/usage/resources/inheritance.md | 409 +++ docs/usage/toc.md | 1 + .../JsonApiDotNetCoreExample/Program.cs | 3 + .../Configuration/ResourceType.cs | 150 +- .../Annotations/RelationshipAttribute.cs | 41 +- .../Annotations/ResourceFieldAttribute.cs | 15 + .../AtomicOperations/LocalIdValidator.cs | 2 +- .../AtomicOperations/OperationsProcessor.cs | 2 +- .../Configuration/IResourceGraph.cs | 6 +- .../Configuration/ResourceGraph.cs | 14 +- .../Configuration/ResourceGraphBuilder.cs | 104 +- .../ServiceCollectionExtensions.cs | 2 +- .../BaseJsonApiOperationsController.cs | 3 +- .../Queries/Expressions/AnyExpression.cs | 14 +- .../Expressions/ComparisonExpression.cs | 5 + .../Queries/Expressions/CountExpression.cs | 5 + .../Queries/Expressions/HasExpression.cs | 14 +- .../Expressions/IncludeChainConverter.cs | 72 - .../Expressions/IncludeElementExpression.cs | 14 +- .../Queries/Expressions/IncludeExpression.cs | 12 +- .../Queries/Expressions/IsTypeExpression.cs | 88 + .../Expressions/LiteralConstantExpression.cs | 5 + .../Queries/Expressions/LogicalExpression.cs | 12 +- .../Expressions/MatchTextExpression.cs | 16 +- .../Queries/Expressions/NotExpression.cs | 5 + .../Expressions/NullConstantExpression.cs | 5 + ...nationElementQueryStringValueExpression.cs | 5 + .../Expressions/PaginationExpression.cs | 5 + .../PaginationQueryStringValueExpression.cs | 7 +- .../Queries/Expressions/QueryExpression.cs | 2 + .../Expressions/QueryExpressionRewriter.cs | 12 + .../Expressions/QueryExpressionVisitor.cs | 5 + .../QueryStringParameterScopeExpression.cs | 5 + .../Expressions/QueryableHandlerExpression.cs | 5 + .../ResourceFieldChainExpression.cs | 5 + .../Expressions/SortElementExpression.cs | 14 +- .../Queries/Expressions/SortExpression.cs | 5 + .../Expressions/SparseFieldSetExpression.cs | 7 +- .../SparseFieldSetExpressionExtensions.cs | 4 +- .../Expressions/SparseFieldTableExpression.cs | 14 +- .../Queries/FieldSelection.cs | 75 + .../Queries/FieldSelectors.cs | 70 + .../Queries/IndentingStringWriter.cs | 41 + .../FieldChainInheritanceRequirement.cs | 17 + .../Queries/Internal/Parsing/FilterParser.cs | 130 +- .../Queries/Internal/Parsing/IncludeParser.cs | 255 +- .../Queries/Internal/Parsing/Keywords.cs | 1 + .../Internal/Parsing/QueryExpressionParser.cs | 38 +- .../Internal/Parsing/QueryTokenizer.cs | 1 + .../Internal/Parsing/ResourceFieldCategory.cs | 8 + .../ResourceFieldChainErrorFormatter.cs | 83 + .../Parsing/ResourceFieldChainResolver.cs | 125 +- .../Queries/Internal/Parsing/SortParser.cs | 30 +- .../Internal/Parsing/SparseFieldTypeParser.cs | 4 +- .../Queries/Internal/Parsing/TokenKind.cs | 1 + .../Queries/Internal/QueryLayerComposer.cs | 125 +- .../QueryableBuilding/IncludeClauseBuilder.cs | 36 +- .../Internal/QueryableBuilding/LambdaScope.cs | 23 +- .../QueryableBuilding/LambdaScopeFactory.cs | 2 +- .../QueryableBuilding/QueryClauseBuilder.cs | 43 +- .../QueryableBuilding/QueryableBuilder.cs | 9 +- .../QueryableBuilding/SelectClauseBuilder.cs | 139 +- .../QueryableBuilding/WhereClauseBuilder.cs | 16 + src/JsonApiDotNetCore/Queries/QueryLayer.cs | 82 +- .../IncludeQueryStringParameterReader.cs | 17 +- .../Repositories/DbContextExtensions.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 58 +- .../IResourceRepositoryAccessor.cs | 21 +- .../Repositories/IResourceWriteRepository.cs | 6 +- .../ResourceRepositoryAccessor.cs | 13 +- .../Resources/AbstractResourceWrapper.cs | 15 + .../Resources/IAbstractResourceWrapper.cs | 12 + .../Resources/IResourceDefinition.cs | 25 +- .../Resources/IResourceDefinitionAccessor.cs | 4 +- .../Resources/IdentifiableComparer.cs | 4 +- .../Resources/IdentifiableExtensions.cs | 13 +- .../Resources/JsonApiResourceDefinition.cs | 36 +- .../Resources/ResourceChangeTracker.cs | 14 +- .../Resources/ResourceDefinitionAccessor.cs | 38 +- .../Resources/ResourceFactory.cs | 29 + .../SortExpressionLambdaConverter.cs | 167 + .../Adapters/ResourceIdentityAdapter.cs | 18 +- .../Response/ResourceObjectTreeNode.cs | 8 +- .../Response/ResponseModelAdapter.cs | 44 +- .../Services/JsonApiResourceService.cs | 184 +- ...eateResourceWithToManyRelationshipTests.cs | 2 +- ...reateResourceWithToOneRelationshipTests.cs | 2 +- .../Transactions/PerformerRepository.cs | 6 +- .../AtomicAddToToManyRelationshipTests.cs | 2 +- ...AtomicRemoveFromToManyRelationshipTests.cs | 2 +- .../AtomicReplaceToManyRelationshipTests.cs | 2 +- .../AtomicUpdateToOneRelationshipTests.cs | 2 +- .../AtomicReplaceToManyRelationshipTests.cs | 2 +- .../Resources/AtomicUpdateResourceTests.cs | 2 +- .../AtomicUpdateToOneRelationshipTests.cs | 2 +- .../CarCompositeKeyAwareRepository.cs | 6 +- .../EagerLoading/BuildingRepository.cs | 4 +- .../HitCountingResourceDefinition.cs | 4 +- .../FireForgetTests.Group.cs | 1 + .../Microservices/MessagingGroupDefinition.cs | 10 +- .../OutboxTests.Group.cs | 1 + .../IntegrationTests/QueryStrings/BlogPost.cs | 3 + .../IntegrationTests/QueryStrings/Human.cs | 22 + .../QueryStrings/Includes/IncludeTests.cs | 22 + .../IntegrationTests/QueryStrings/Man.cs | 27 + .../QueryStrings/QueryStringDbContext.cs | 8 + .../QueryStrings/WebAccount.cs | 3 + .../IntegrationTests/QueryStrings/Woman.cs | 24 + .../ReadWrite/Creating/CreateResourceTests.cs | 2 +- ...eateResourceWithToManyRelationshipTests.cs | 2 +- ...reateResourceWithToOneRelationshipTests.cs | 2 +- .../AddToToManyRelationshipTests.cs | 2 +- .../RemoveFromToManyRelationshipTests.cs | 2 +- .../ReplaceToManyRelationshipTests.cs | 2 +- .../UpdateToOneRelationshipTests.cs | 2 +- .../ReplaceToManyRelationshipTests.cs | 2 +- .../Updating/Resources/UpdateResourceTests.cs | 2 +- .../Resources/UpdateToOneRelationshipTests.cs | 2 +- .../ResourceInheritance/Book.cs | 11 - .../ChangeTracking/ChangeTrackingDbContext.cs | 16 + .../ResourceInheritanceChangeTrackerTests.cs | 62 + .../FamilyHealthInsurance.cs | 11 - .../ResourceInheritance/Human.cs | 24 - .../InheritanceDbContext.cs | 38 - .../ResourceInheritance/InheritanceFakers.cs | 55 - .../ResourceInheritance/InheritanceTests.cs | 458 --- .../Models/AlwaysMovingTandem.cs | 18 + .../Models/BicycleLight.cs | 13 + .../ResourceInheritance/Models/Bike.cs | 21 + .../ResourceInheritance/Models/Box.cs | 19 + .../ResourceInheritance/Models/Car.cs | 15 + .../{Man.cs => Models/CarbonWheel.cs} | 6 +- .../ChromeWheel.cs} | 7 +- .../Cylinder.cs} | 7 +- .../Models/DieselEngine.cs | 18 + .../ResourceInheritance/Models/Engine.cs | 16 + .../Models/GasolineEngine.cs | 21 + .../Models/GenericFeature.cs | 16 + .../Models/GenericProperty.cs | 13 + .../Models/MotorVehicle.cs | 21 + .../Models/NavigationSystem.cs | 13 + .../Models/NumberProperty.cs | 12 + .../{ContentItem.cs => Models/NumberValue.cs} | 7 +- .../Models/StringProperty.cs | 12 + .../ResourceInheritance/Models/StringValue.cs | 13 + .../ResourceInheritance/Models/Tandem.cs | 15 + .../ResourceInheritance/Models/Truck.cs | 18 + .../ResourceInheritance/Models/Vehicle.cs | 22 + .../Models/VehicleManufacturer.cs | 16 + .../ResourceInheritance/Models/Wheel.cs | 16 + .../ResourceInheritanceDbContext.cs | 39 + .../ResourceInheritanceFakers.cs | 149 + .../ResourceInheritanceReadTests.cs | 2520 +++++++++++++++ .../ResourceInheritanceWriteTests.cs | 2741 +++++++++++++++++ .../ResourceTypeCaptureStore.cs | 44 + .../ResourceTypeCapturingDefinition.cs | 120 + .../TablePerHierarchyDbContext.cs | 13 + .../TablePerHierarchyReadTests.cs | 13 + .../TablePerHierarchyWriteTests.cs | 13 + .../TablePerType/TablePerTypeDbContext.cs | 36 + .../TablePerType/TablePerTypeReadTests.cs | 13 + .../TablePerType/TablePerTypeWriteTests.cs | 13 + .../ResourceInheritance/Video.cs | 11 - .../WheelSortDefinition.cs | 69 + .../ResourceInheritance/Woman.cs | 11 - .../UnitTests/Links/LinkInclusionTests.cs | 4 +- .../Queries/QueryExpressionRewriterTests.cs | 212 ++ .../TestableQueryExpressionRewriter.cs | 151 + .../QueryStringParameters/BaseParseTests.cs | 3 + .../QueryStringParameters/FilterParseTests.cs | 13 + .../IncludeParseTests.cs | 13 + .../LegacyFilterParseTests.cs | 1 - .../QueryStringParameters/SortParseTests.cs | 11 + .../SparseFieldSetParseTests.cs | 2 +- .../CreateSortExpressionFromLambdaTests.cs | 428 +++ .../ResourceGraphBuilderTests.cs | 88 + .../Serialization/Response/FakeLinkBuilder.cs | 25 + .../Serialization/Response/FakeMetaBuilder.cs | 15 + .../FakeRequestQueryStringAccessor.cs | 9 + .../FakeResourceDefinitionAccessor.cs | 100 + .../Response/IncompleteResourceGraphTests.cs | 64 + .../Response/ResponseModelAdapterTests.cs | 132 - .../TestBuildingBlocks/DbContextExtensions.cs | 8 - .../NullabilityAssertionExtensions.cs | 7 + .../ServiceCollectionExtensionsTests.cs | 21 +- 190 files changed, 10178 insertions(+), 1391 deletions(-) create mode 100644 docs/usage/resources/inheritance.md create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/FieldSelection.cs create mode 100644 src/JsonApiDotNetCore/Queries/FieldSelectors.cs create mode 100644 src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs create mode 100644 src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs create mode 100644 src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs create mode 100644 src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Human.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Man.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Woman.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Book.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ChangeTracking/ChangeTrackingDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ChangeTracking/ResourceInheritanceChangeTrackerTests.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/FamilyHealthInsurance.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Human.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceFakers.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/AlwaysMovingTandem.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/BicycleLight.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Bike.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Box.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Car.cs rename test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/{Man.cs => Models/CarbonWheel.cs} (76%) rename test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/{CompanyHealthInsurance.cs => Models/ChromeWheel.cs} (51%) rename test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/{HealthInsurance.cs => Models/Cylinder.cs} (54%) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/DieselEngine.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Engine.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/GasolineEngine.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/GenericFeature.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/GenericProperty.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/MotorVehicle.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/NavigationSystem.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/NumberProperty.cs rename test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/{ContentItem.cs => Models/NumberValue.cs} (54%) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/StringProperty.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/StringValue.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Tandem.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Truck.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Vehicle.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/VehicleManufacturer.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Wheel.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceFakers.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceTypeCaptureStore.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceTypeCapturingDefinition.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerHierarchy/TablePerHierarchyDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerHierarchy/TablePerHierarchyReadTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerHierarchy/TablePerHierarchyWriteTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeReadTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeWriteTests.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Video.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/WheelSortDefinition.cs delete mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Woman.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeLinkBuilder.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeMetaBuilder.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeRequestQueryStringAccessor.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index c4f0d3d36d..2a7eb28d9b 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -629,6 +629,7 @@ $left$ = $right$; $collection$.IsNullOrEmpty() $collection$ == null || !$collection$.Any() WARNING + True True True True diff --git a/ROADMAP.md b/ROADMAP.md index 559e0bfe7d..c3baab5443 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,13 +27,13 @@ The need for breaking changes has blocked several efforts in the v4.x release, s - [x] Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365) - [x] Support .NET 6 with EF Core 6 [#1109](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1109) - [x] Extract annotations into separate package [#730](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/730) +- [x] Resource inheritance [#844](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844) Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. - Optimistic concurrency [#1004](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1004) - OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046) - Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776) -- Resource inheritance [#844](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844) - Idempotency ## Feedback diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index 6b716a5401..12f5c2e788 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -127,11 +127,19 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); - ImmutableArray chain = ImmutableArray.Create(single2, single3, multi4, multi5); - IEnumerable chains = new ResourceFieldChainExpression(chain).AsEnumerable(); - - var converter = new IncludeChainConverter(); - IncludeExpression include = converter.FromRelationshipChains(chains); + var include = new IncludeExpression(new HashSet + { + new(single2, new HashSet + { + new(single3, new HashSet + { + new(multi4, new HashSet + { + new(multi5) + }.ToImmutableHashSet()) + }.ToImmutableHashSet()) + }.ToImmutableHashSet()) + }.ToImmutableHashSet()); var cache = new EvaluatedIncludeCache(); cache.Set(include); diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index befa5049e8..100ff60e25 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -180,9 +180,9 @@ public Task OnSetToManyRelationshipAsync(TResource leftResource, HasM return Task.CompletedTask; } - public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + where TResource : class, IIdentifiable { return Task.CompletedTask; } diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index dab1fdb6e2..a1c1215ccd 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -24,6 +24,7 @@ Expressions are composed using the following functions: | Ends with text | `endsWith` | `?filter=endsWith(description,'End')` | | Equals one value from set | `any` | `?filter=any(chapter,'Intro','Summary','Conclusion')` | | Collection contains items | `has` | `?filter=has(articles)` | +| Type-check derived type (v5) | `isType` | `?filter=isType(,men)` | | Negation | `not` | `?filter=not(equals(lastName,null))` | | Conditional logical OR | `or` | `?filter=or(has(orders),has(invoices))` | | Conditional logical AND | `and` | `?filter=and(has(orders),has(invoices))` | @@ -86,6 +87,32 @@ GET /customers?filter=has(orders,not(equals(status,'Paid'))) HTTP/1.1 Which returns only customers that have at least one unpaid order. +_since v5.0_ + +Use the `isType` filter function to perform a type check on a derived type. You can pass a nested filter, where the derived fields are accessible. + +Only return men: +```http +GET /humans?filter=isType(,men) HTTP/1.1 +``` + +Only return men with beards: +```http +GET /humans?filter=isType(,men,equals(hasBeard,'true')) HTTP/1.1 +``` + +The first parameter of `isType` can be used to perform the type check on a to-one relationship path. + +Only return people whose best friend is a man with children: +```http +GET /humans?filter=isType(bestFriend,men,has(children)) HTTP/1.1 +``` + +Only return people who have at least one female married child: +```http +GET /humans?filter=has(children,isType(,woman,not(equals(husband,null)))) HTTP/1.1 +``` + # Legacy filters The next section describes how filtering worked in versions prior to v4.0. They are always applied on the set of resources being requested (no nesting). diff --git a/docs/usage/resources/inheritance.md b/docs/usage/resources/inheritance.md new file mode 100644 index 0000000000..47cf85ca67 --- /dev/null +++ b/docs/usage/resources/inheritance.md @@ -0,0 +1,409 @@ +# Resource inheritance + +_since v5.0_ + +Resource classes can be part of a type hierarchy. For example: + +```c# +#nullable enable + +public abstract class Human : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasOne] + public Man? Father { get; set; } + + [HasOne] + public Woman? Mother { get; set; } + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + + [HasOne] + public Human? BestFriend { get; set; } +} + +public sealed class Man : Human +{ + [Attr] + public bool HasBeard { get; set; } + + [HasOne] + public Woman? Wife { get; set; } +} + +public sealed class Woman : Human +{ + [Attr] + public string? MaidenName { get; set; } + + [HasOne] + public Man? Husband { get; set; } +} +``` + +## Reading data + +You can access them through base or derived endpoints. + +```http +GET /humans HTTP/1.1 + +{ + "data": [ + { + "type": "women", + "id": "1", + "attributes": { + "maidenName": "Smith", + "name": "Jane Doe" + }, + "relationships": { + "husband": { + "links": { + "self": "/women/1/relationships/husband", + "related": "/women/1/husband" + } + }, + "father": { + "links": { + "self": "/women/1/relationships/father", + "related": "/women/1/father" + } + }, + "mother": { + "links": { + "self": "/women/1/relationships/mother", + "related": "/women/1/mother" + } + }, + "children": { + "links": { + "self": "/women/1/relationships/children", + "related": "/women/1/children" + } + }, + "bestFriend": { + "links": { + "self": "/women/1/relationships/bestFriend", + "related": "/women/1/bestFriend" + } + } + }, + "links": { + "self": "/women/1" + } + }, + { + "type": "men", + "id": "2", + "attributes": { + "hasBeard": true, + "name": "John Doe" + }, + "relationships": { + "wife": { + "links": { + "self": "/men/2/relationships/wife", + "related": "/men/2/wife" + } + }, + "father": { + "links": { + "self": "/men/2/relationships/father", + "related": "/men/2/father" + } + }, + "mother": { + "links": { + "self": "/men/2/relationships/mother", + "related": "/men/2/mother" + } + }, + "children": { + "links": { + "self": "/men/2/relationships/children", + "related": "/men/2/children" + } + }, + "bestFriend": { + "links": { + "self": "/men/2/relationships/bestFriend", + "related": "/men/2/bestFriend" + } + } + }, + "links": { + "self": "/men/2" + } + } + ] +} +``` + +### Spare fieldsets + +If you only want to retrieve the fields from the base type, you can use [sparse fieldsets](~/usage/reading/sparse-fieldset-selection.md). + +```http +GET /humans?fields[men]=name,children&fields[women]=name,children HTTP/1.1 +``` + +### Includes + +Relationships on derived types can be included without special syntax. + +```http +GET /humans?include=husband,wife,children HTTP/1.1 +``` + +### Sorting + +Just like includes, you can sort on derived attributes and relationships. + +```http +GET /humans?sort=maidenName,wife.name HTTP/1.1 +``` + +This returns all women sorted by their maiden names, followed by all men sorted by the name of their wife. + +To accomplish the same from a [Resource Definition](~/usage/extensibility/resource-definitions.md), upcast to the derived type: + +```c# +public override SortExpression OnApplySort(SortExpression? existingSort) +{ + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (human => ((Woman)human).MaidenName, ListSortDirection.Ascending), + (human => ((Man)human).Wife!.Name, ListSortDirection.Ascending) + }); +} +``` + +### Filtering + +Use the `isType` filter function to perform a type check on a derived type. You can pass a nested filter, where the derived fields are accessible. + +Only return men: +```http +GET /humans?filter=isType(,men) HTTP/1.1 +``` + +Only return men with beards: +```http +GET /humans?filter=isType(,men,equals(hasBeard,'true')) HTTP/1.1 +``` + +The first parameter of `isType` can be used to perform the type check on a to-one relationship path. + +Only return people whose best friend is a man with children: +```http +GET /humans?filter=isType(bestFriend,men,has(children)) HTTP/1.1 +``` + +Only return people who have at least one female married child: +```http +GET /humans?filter=has(children,isType(,woman,not(equals(husband,null)))) HTTP/1.1 +``` + +## Writing data + +Just like reading data, you can use base or derived endpoints. When using relationships in request bodies, you can use base or derived types as well. +The only exception is that you cannot use an abstract base type in the request body when creating or updating a resource. + +For example, updating an attribute and relationship can be done at an abstract endpoint, but its body requires non-abstract types: + +```http +PATCH /humans/2 HTTP/1.1 + +{ + "data": { + "type": "men", + "id": "2", + "attributes": { + "hasBeard": false + }, + "relationships": { + "wife": { + "data": { + "type": "women", + "id": "1" + } + } + } + } +} +``` + +Updating a relationship does allow abstract types. For example: + +```http +PATCH /humans/1/relationships/children HTTP/1.1 + +{ + "data": [ + { + "type": "humans", + "id": "2" + } + ] +} +``` + +### Request pipeline + +The `TResource` type parameter used in controllers, resource services and resource repositories always matches the used endpoint. +But when JsonApiDotNetCore sees usage of a type from a type hierarchy, it fetches the stored types and updates `IJsonApiRequest` accordingly. +As a result, `TResource` can be different from what `IJsonApiRequest.PrimaryResourceType` returns. + +For example, on the request: +```http + GET /humans/1 HTTP/1.1 +``` + +JsonApiDotNetCore runs `IResourceService`, but `IJsonApiRequest.PrimaryResourceType` returns `Woman` +if human with ID 1 is stored as a woman in the underlying data store. + +Even with a simple type hierarchy as used here, lots of possible combinations quickly arise. For example, changing someone's best friend can be done using the following requests: +- `PATCH /humans/1/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }` +- `PATCH /humans/1/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }` +- `PATCH /women/1/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }` +- `PATCH /women/1/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }` +- `PATCH /men/2/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }` +- `PATCH /men/2/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }` +- `PATCH /humans/1/relationships/bestFriend { "data": { type: "human" ... } }` +- `PATCH /humans/1/relationships/bestFriend { "data": { type: "women" ... } }` +- `PATCH /humans/1/relationships/bestFriend { "data": { type: "men" ... } }` +- `PATCH /women/1/relationships/bestFriend { "data": { type: "human" ... } }` +- `PATCH /women/1/relationships/bestFriend { "data": { type: "women" ... } }` +- `PATCH /women/1/relationships/bestFriend { "data": { type: "men" ... } }` +- `PATCH /men/2/relationships/bestFriend { "data": { type: "human" ... } }` +- `PATCH /men/2/relationships/bestFriend { "data": { type: "women" ... } }` +- `PATCH /men/2/relationships/bestFriend { "data": { type: "men" ... } }` + +Because of all the possible combinations, implementing business rules in the pipeline is a no-go. +Resource definitions provide a better solution, see below. + +### Resource definitions + +In contrast to the request pipeline, JsonApiDotNetCore always executes the resource definition that matches the *stored* type. +This enables to implement business logic in a central place, irrespective of which endpoint was used or whether base types were used in relationships. + +To delegate logic for base types to their matching resource type, you can build a chain of resource definitions. And because you'll always get the +actually stored types (for relationships too), you can type-check left-side and right-side types in resources definitions. + +```c# +public sealed class HumanDefinition : JsonApiResourceDefinition +{ + public HumanDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public override Task OnSetToOneRelationshipAsync(Human leftResource, + HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (leftResource is Man && + hasOneRelationship.Property.Name == nameof(Human.BestFriend) && + rightResourceId is Woman) + { + throw new Exception("Men are not supposed to have a female best friend."); + } + + return Task.FromResult(rightResourceId); + } + + public override Task OnWritingAsync(Human resource, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + if (writeOperation is WriteOperationKind.CreateResource or + WriteOperationKind.UpdateResource) + { + if (resource is Man { HasBeard: true }) + { + throw new Exception("Only shaved men, please."); + } + } + + return Task.CompletedTask; + } +} + +public sealed class WomanDefinition : JsonApiResourceDefinition +{ + private readonly IResourceDefinition _baseDefinition; + + public WomanDefinition(IResourceGraph resourceGraph, + IResourceDefinition baseDefinition) + : base(resourceGraph) + { + _baseDefinition = baseDefinition; + } + + public override Task OnSetToOneRelationshipAsync(Woman leftResource, + HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ResourceType.BaseType!.FindRelationshipByPublicName( + hasOneRelationship.PublicName) != null) + { + // Delegate to resource definition for base type Human. + return _baseDefinition.OnSetToOneRelationshipAsync(leftResource, + hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + } + + // Handle here. + if (hasOneRelationship.Property.Name == nameof(Woman.Husband) && + rightResourceId == null) + { + throw new Exception("We don't accept unmarried women at this time."); + } + + return Task.FromResult(rightResourceId); + } + + public override async Task OnPrepareWriteAsync(Woman resource, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + // Run rules in resource definition for base type Human. + await _baseDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + + // Run rules for type Woman. + if (resource.MaidenName == null) + { + throw new Exception("Women should have a maiden name."); + } + } +} + +public sealed class ManDefinition : JsonApiResourceDefinition +{ + private readonly IResourceDefinition _baseDefinition; + + public ManDefinition(IResourceGraph resourceGraph, + IResourceDefinition baseDefinition) + : base(resourceGraph) + { + _baseDefinition = baseDefinition; + } + + public override Task OnSetToOneRelationshipAsync(Man leftResource, + HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + // No man-specific logic, but we'll still need to delegate. + return _baseDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, + rightResourceId, writeOperation, cancellationToken); + } + + public override Task OnWritingAsync(Man resource, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + // No man-specific logic, but we'll still need to delegate. + return _baseDefinition.OnWritingAsync(resource, writeOperation, cancellationToken); + } +} +``` diff --git a/docs/usage/toc.md b/docs/usage/toc.md index f6924036ea..c30a2b0f37 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -1,6 +1,7 @@ # [Resources](resources/index.md) ## [Attributes](resources/attributes.md) ## [Relationships](resources/relationships.md) +## [Inheritance](resources/inheritance.md) ## [Nullability](resources/nullability.md) # Reading data diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 482e42cdf7..bda826d131 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; @@ -6,6 +7,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection.Extensions; +[assembly: ExcludeFromCodeCoverage] + WebApplication app = CreateWebApplication(args); await CreateDatabaseAsync(app.Services); diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index ce7ccd1870..c9b2f8064d 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -11,6 +11,7 @@ public sealed class ResourceType { private readonly Dictionary _fieldsByPublicName = new(); private readonly Dictionary _fieldsByPropertyName = new(); + private readonly Lazy> _lazyAllConcreteDerivedTypes; /// /// The publicly exposed resource name. @@ -28,22 +29,35 @@ public sealed class ResourceType public Type IdentityClrType { get; } /// - /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. + /// The base resource type, in case this is a derived type. + /// + public ResourceType? BaseType { get; internal set; } + + /// + /// The resource types that directly derive from this one. + /// + public IReadOnlySet DirectlyDerivedTypes { get; internal set; } = new HashSet(); + + /// + /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. When using resource inheritance, this + /// includes the attributes and relationships from base types. /// public IReadOnlyCollection Fields { get; } /// - /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. + /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. When using resource inheritance, this includes the + /// attributes from base types. /// public IReadOnlyCollection Attributes { get; } /// - /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. + /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. When using resource inheritance, this + /// includes the relationships from base types. /// public IReadOnlyCollection Relationships { get; } /// - /// Related entities that are not exposed as resource relationships. + /// Related entities that are not exposed as resource relationships. When using resource inheritance, this includes the eager-loads from base types. /// public IReadOnlyCollection EagerLoads { get; } @@ -100,6 +114,29 @@ public ResourceType(string publicName, Type clrType, Type identityClrType, IRead _fieldsByPublicName.Add(field.PublicName, field); _fieldsByPropertyName.Add(field.Property.Name, field); } + + _lazyAllConcreteDerivedTypes = new Lazy>(ResolveAllConcreteDerivedTypes, LazyThreadSafetyMode.PublicationOnly); + } + + private IReadOnlySet ResolveAllConcreteDerivedTypes() + { + var allConcreteDerivedTypes = new HashSet(); + AddConcreteDerivedTypes(this, allConcreteDerivedTypes); + + return allConcreteDerivedTypes; + } + + private static void AddConcreteDerivedTypes(ResourceType resourceType, ISet allConcreteDerivedTypes) + { + foreach (ResourceType derivedType in resourceType.DirectlyDerivedTypes) + { + if (!derivedType.ClrType.IsAbstract) + { + allConcreteDerivedTypes.Add(derivedType); + } + + AddConcreteDerivedTypes(derivedType, allConcreteDerivedTypes); + } } public AttrAttribute GetAttributeByPublicName(string publicName) @@ -161,6 +198,111 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) : null; } + /// + /// Returns all directly and indirectly non-abstract resource types that derive from this resource type. + /// + public IReadOnlySet GetAllConcreteDerivedTypes() + { + return _lazyAllConcreteDerivedTypes.Value; + } + + /// + /// Searches the tree of derived types to find a match for the specified . + /// + public ResourceType GetTypeOrDerived(Type clrType) + { + ArgumentGuard.NotNull(clrType, nameof(clrType)); + + ResourceType? derivedType = FindTypeOrDerived(this, clrType); + + if (derivedType == null) + { + throw new InvalidOperationException($"Resource type '{PublicName}' is not a base type of '{clrType}'."); + } + + return derivedType; + } + + private static ResourceType? FindTypeOrDerived(ResourceType type, Type clrType) + { + if (type.ClrType == clrType) + { + return type; + } + + foreach (ResourceType derivedType in type.DirectlyDerivedTypes) + { + ResourceType? matchingType = FindTypeOrDerived(derivedType, clrType); + + if (matchingType != null) + { + return matchingType; + } + } + + return null; + } + + internal IReadOnlySet GetAttributesInTypeOrDerived(string publicName) + { + return GetAttributesInTypeOrDerived(this, publicName); + } + + private static IReadOnlySet GetAttributesInTypeOrDerived(ResourceType resourceType, string publicName) + { + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + return attribute.AsHashSet(); + } + + // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported. + // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords + HashSet attributesInDerivedTypes = new(); + + foreach (AttrAttribute attributeInDerivedType in resourceType.DirectlyDerivedTypes + .Select(derivedType => GetAttributesInTypeOrDerived(derivedType, publicName)).SelectMany(attributesInDerivedType => attributesInDerivedType)) + { + attributesInDerivedTypes.Add(attributeInDerivedType); + } + + return attributesInDerivedTypes; + } + + internal IReadOnlySet GetRelationshipsInTypeOrDerived(string publicName) + { + return GetRelationshipsInTypeOrDerived(this, publicName); + } + + private static IReadOnlySet GetRelationshipsInTypeOrDerived(ResourceType resourceType, string publicName) + { + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); + + if (relationship != null) + { + return relationship.AsHashSet(); + } + + // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported. + // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords + HashSet relationshipsInDerivedTypes = new(); + + foreach (RelationshipAttribute relationshipInDerivedType in resourceType.DirectlyDerivedTypes + .Select(derivedType => GetRelationshipsInTypeOrDerived(derivedType, publicName)) + .SelectMany(relationshipsInDerivedType => relationshipsInDerivedType)) + { + relationshipsInDerivedTypes.Add(relationshipInDerivedType); + } + + return relationshipsInDerivedTypes; + } + + internal bool IsPartOfTypeHierarchy() + { + return BaseType != null || DirectlyDerivedTypes.Any(); + } + public override string ToString() { return PublicName; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index 6406ba17ff..11320a7abc 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -14,21 +14,8 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute { private protected static readonly CollectionConverter CollectionConverter = new(); - /// - /// The CLR type in which this relationship is declared. - /// - internal Type? LeftClrType { get; set; } - - /// - /// The CLR type this relationship points to. In the case of a relationship, this value will be the collection element - /// type. - /// - /// - /// Tags { get; set; } // RightClrType: typeof(Tag) - /// ]]> - /// - internal Type? RightClrType { get; set; } + // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. + private ResourceType? _rightType; /// /// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed @@ -52,15 +39,28 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public PropertyInfo? InverseNavigationProperty { get; set; } /// - /// The containing resource type in which this relationship is declared. + /// The containing resource type in which this relationship is declared. Identical to . /// - public ResourceType LeftType { get; internal set; } = null!; + public ResourceType LeftType => Type; /// /// The resource type this relationship points to. In the case of a relationship, this value will be the collection /// element type. /// - public ResourceType RightType { get; internal set; } = null!; + /// + /// Tags { get; set; } // RightType: Tag + /// ]]> + /// + public ResourceType RightType + { + get => _rightType!; + internal set + { + ArgumentGuard.NotNull(value, nameof(value)); + _rightType = value; + } + } /// /// Configures which links to write in the relationship-level links object for this relationship. Defaults to , @@ -91,12 +91,11 @@ public override bool Equals(object? obj) var other = (RelationshipAttribute)obj; - return LeftClrType == other.LeftClrType && RightClrType == other.RightClrType && Links == other.Links && CanInclude == other.CanInclude && - base.Equals(other); + return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && CanInclude == other.CanInclude && base.Equals(other); } public override int GetHashCode() { - return HashCode.Combine(LeftClrType, RightClrType, Links, CanInclude, base.GetHashCode()); + return HashCode.Combine(_rightType?.ClrType, Links, CanInclude, base.GetHashCode()); } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index c7a44f59c8..599b17a42a 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -1,5 +1,6 @@ using System.Reflection; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; // ReSharper disable NonReadonlyMemberInGetHashCode @@ -15,6 +16,7 @@ public abstract class ResourceFieldAttribute : Attribute // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. private string? _publicName; private PropertyInfo? _property; + private ResourceType? _type; /// /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. @@ -46,6 +48,19 @@ internal set } } + /// + /// The containing resource type in which this field is declared. + /// + public ResourceType Type + { + get => _type!; + internal set + { + ArgumentGuard.NotNull(value, nameof(value)); + _type = value; + } + } + /// /// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the /// specified resource instance. diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index cbbc702d0c..9cb463ab10 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -96,7 +96,7 @@ private void AssertLocalIdIsAssigned(IIdentifiable resource) { if (resource.LocalId != null) { - ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetClrType()); _localIdTracker.GetValue(resource.LocalId, resourceType); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 4a26e36710..6524252abf 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -140,7 +140,7 @@ private void AssignStringId(IIdentifiable resource) { if (resource.LocalId != null) { - ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetClrType()); resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType); } } diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index 9ccce6c60a..a98b74c3fe 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -54,7 +54,7 @@ ResourceType GetResourceType() /// (TResource resource) => new { resource.Attribute1, resource.Relationship2 } /// ]]> /// - IReadOnlyCollection GetFields(Expression> selector) + IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable; /// @@ -68,7 +68,7 @@ IReadOnlyCollection GetFields(Expression new { resource.attribute1, resource.Attribute2 } /// ]]> /// - IReadOnlyCollection GetAttributes(Expression> selector) + IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable; /// @@ -82,6 +82,6 @@ IReadOnlyCollection GetAttributes(Expression new { resource.Relationship1, resource.Relationship2 } /// ]]> /// - IReadOnlyCollection GetRelationships(Expression> selector) + IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index d5acc8a1f9..d693fa2c3c 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -42,7 +42,7 @@ public ResourceType GetResourceType(string publicName) if (resourceType == null) { - throw new InvalidOperationException($"Resource type '{publicName}' does not exist."); + throw new InvalidOperationException($"Resource type '{publicName}' does not exist in the resource graph."); } return resourceType; @@ -63,7 +63,7 @@ public ResourceType GetResourceType(Type resourceClrType) if (resourceType == null) { - throw new InvalidOperationException($"Resource of type '{resourceClrType.Name}' does not exist."); + throw new InvalidOperationException($"Type '{resourceClrType}' does not exist in the resource graph."); } return resourceType; @@ -91,7 +91,7 @@ public ResourceType GetResourceType() } /// - public IReadOnlyCollection GetFields(Expression> selector) + public IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -100,7 +100,7 @@ public IReadOnlyCollection GetFields(Expressi } /// - public IReadOnlyCollection GetAttributes(Expression> selector) + public IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -109,7 +109,7 @@ public IReadOnlyCollection GetAttributes(Expression - public IReadOnlyCollection GetRelationships(Expression> selector) + public IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -117,7 +117,7 @@ public IReadOnlyCollection GetRelationships(Ex return FilterFields(selector); } - private IReadOnlyCollection FilterFields(Expression> selector) + private IReadOnlyCollection FilterFields(Expression> selector) where TResource : class, IIdentifiable where TField : ResourceFieldAttribute { @@ -157,7 +157,7 @@ private IReadOnlyCollection GetFieldsOfType() return (IReadOnlyCollection)resourceType.Fields; } - private IEnumerable ToMemberNames(Expression> selector) + private IEnumerable ToMemberNames(Expression> selector) { Expression selectorBody = RemoveConvert(selector.Body); diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 1352160f11..428b14d32e 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -43,21 +43,103 @@ public IResourceGraph Build() var resourceGraph = new ResourceGraph(resourceTypes); - foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships)) + SetFieldTypes(resourceGraph); + SetRelationshipTypes(resourceGraph); + SetDirectlyDerivedTypes(resourceGraph); + ValidateFieldsInDerivedTypes(resourceGraph); + + return resourceGraph; + } + + private static void SetFieldTypes(ResourceGraph resourceGraph) + { + foreach (ResourceFieldAttribute field in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Fields)) { - relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!); - ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!); + field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!); + } + } + + private static void SetRelationshipTypes(ResourceGraph resourceGraph) + { + foreach (RelationshipAttribute relationship in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships)) + { + Type rightClrType = relationship is HasOneAttribute + ? relationship.Property.PropertyType + : relationship.Property.PropertyType.GetGenericArguments()[0]; + + ResourceType? rightType = resourceGraph.FindResourceType(rightClrType); if (rightType == null) { - throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " + - $"'{relationship.RightClrType}', which was not added to the resource graph."); + throw new InvalidConfigurationException($"Resource type '{relationship.LeftType.ClrType}' depends on " + + $"'{rightClrType}', which was not added to the resource graph."); } relationship.RightType = rightType; } + } - return resourceGraph; + private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph) + { + Dictionary> directlyDerivedTypesPerBaseType = new(); + + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + ResourceType? baseType = resourceGraph.FindResourceType(resourceType.ClrType.BaseType!); + + if (baseType != null) + { + resourceType.BaseType = baseType; + + if (!directlyDerivedTypesPerBaseType.ContainsKey(baseType)) + { + directlyDerivedTypesPerBaseType[baseType] = new HashSet(); + } + + directlyDerivedTypesPerBaseType[baseType].Add(resourceType); + } + } + + foreach ((ResourceType baseType, HashSet directlyDerivedTypes) in directlyDerivedTypesPerBaseType) + { + baseType.DirectlyDerivedTypes = directlyDerivedTypes; + } + } + + private void ValidateFieldsInDerivedTypes(ResourceGraph resourceGraph) + { + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + if (resourceType.BaseType != null) + { + ValidateAttributesInDerivedType(resourceType); + ValidateRelationshipsInDerivedType(resourceType); + } + } + } + + private static void ValidateAttributesInDerivedType(ResourceType resourceType) + { + foreach (AttrAttribute attribute in resourceType.BaseType!.Attributes) + { + if (resourceType.FindAttributeByPublicName(attribute.PublicName) == null) + { + throw new InvalidConfigurationException($"Attribute '{attribute.PublicName}' from base type " + + $"'{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); + } + } + } + + private static void ValidateRelationshipsInDerivedType(ResourceType resourceType) + { + foreach (RelationshipAttribute relationship in resourceType.BaseType!.Relationships) + { + if (resourceType.FindRelationshipByPublicName(relationship.PublicName) == null) + { + throw new InvalidConfigurationException($"Relationship '{relationship.PublicName}' from base type " + + $"'{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); + } + } } public ResourceGraphBuilder Add(DbContext dbContext) @@ -221,8 +303,6 @@ private IReadOnlyCollection GetRelationships(Type resourc { relationship.Property = property; SetPublicName(relationship, property); - relationship.LeftClrType = resourceClrType; - relationship.RightClrType = GetRelationshipType(relationship, property); IncludeField(relationshipsByName, relationship); } @@ -237,14 +317,6 @@ private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) field.PublicName ??= FormatPropertyName(property); } - private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(property, nameof(property)); - - return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; - } - private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) { AssertNoInfiniteRecursion(recursionDepth); diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 64a15e2eda..64a99021d4 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -111,7 +111,7 @@ private static void RegisterTypeForUnboundInterfaces(IServiceCollection serviceC if (!seenCompatibleInterface) { - throw new InvalidConfigurationException($"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); + throw new InvalidConfigurationException($"Type '{implementationType}' does not implement any of the expected JsonApiDotNetCore interfaces."); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index debcfa5c6f..a948d67edc 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -148,7 +148,8 @@ protected virtual void ValidateModelState(IList operations) Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors, - _resourceGraph, (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetType() : null); + _resourceGraph, + (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetClrType() : null); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index be8a22abee..980a7846bc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -34,14 +34,24 @@ public override TResult Accept(QueryExpressionVisitor constant.ToString()).OrderBy(value => value))); + builder.Append(string.Join(",", Constants.Select(constant => toFullString ? constant.ToFullString() : constant.ToString()).OrderBy(value => value))); builder.Append(')'); return builder.ToString(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index 4c858bd743..9bf1c3bde8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -33,6 +33,11 @@ public override string ToString() return $"{Operator.ToString().Camelize()}({Left},{Right})"; } + public override string ToFullString() + { + return $"{Operator.ToString().Camelize()}({Left.ToFullString()},{Right.ToFullString()})"; + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 1bab96eaab..5de89ead7c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -28,6 +28,11 @@ public override string ToString() return $"{Keywords.Count}({TargetCollection})"; } + public override string ToFullString() + { + return $"{Keywords.Count}({TargetCollection.ToFullString()})"; + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index d1376a3091..c5387106d6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -27,16 +27,26 @@ public override TResult Accept(QueryExpressionVisitor GetRelationshipChains(I return converter.Chains; } - /// - /// Converts a set of relationship chains into a tree of inclusions. - /// - /// - /// Input chains: Blog, - /// Article -> Revisions -> Author - /// ]]> Output tree: - /// - /// - public IncludeExpression FromRelationshipChains(IEnumerable chains) - { - ArgumentGuard.NotNull(chains, nameof(chains)); - - IImmutableSet elements = ConvertChainsToElements(chains); - return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; - } - - private static IImmutableSet ConvertChainsToElements(IEnumerable chains) - { - var rootNode = new MutableIncludeNode(null!); - - foreach (ResourceFieldChainExpression chain in chains) - { - ConvertChainToElement(chain, rootNode); - } - - return rootNode.Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); - } - - private static void ConvertChainToElement(ResourceFieldChainExpression chain, MutableIncludeNode rootNode) - { - MutableIncludeNode currentNode = rootNode; - - foreach (RelationshipAttribute relationship in chain.Fields.OfType()) - { - if (!currentNode.Children.ContainsKey(relationship)) - { - currentNode.Children[relationship] = new MutableIncludeNode(relationship); - } - - currentNode = currentNode.Children[relationship]; - } - } - private sealed class IncludeToChainsConverter : QueryExpressionVisitor { private readonly Stack _parentRelationshipStack = new(); @@ -144,22 +90,4 @@ private void FlushChain(IncludeElementExpression expression) Chains.Add(new ResourceFieldChainExpression(chainBuilder.ToImmutable())); } } - - private sealed class MutableIncludeNode - { - private readonly RelationshipAttribute _relationship; - - public IDictionary Children { get; } = new Dictionary(); - - public MutableIncludeNode(RelationshipAttribute relationship) - { - _relationship = relationship; - } - - public IncludeElementExpression ToExpression() - { - IImmutableSet elementChildren = Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); - return new IncludeElementExpression(_relationship, elementChildren); - } - } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index cd95ef61a3..e76aaf0946 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -34,14 +34,24 @@ public override TResult Accept(QueryExpressionVisitor child.ToString()).OrderBy(name => name))); + builder.Append(string.Join(",", Children.Select(child => toFullString ? child.ToFullString() : child.ToString()).OrderBy(name => name))); builder.Append('}'); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 4597570ba3..a63d87719d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -33,9 +33,19 @@ public override TResult Accept(QueryExpressionVisitor chains = IncludeChainConverter.GetRelationshipChains(this); - return string.Join(",", chains.Select(child => child.ToString()).OrderBy(name => name)); + return string.Join(",", chains.Select(field => toFullString ? field.ToFullString() : field.ToString()).OrderBy(name => name)); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs new file mode 100644 index 0000000000..a30e31308b --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs @@ -0,0 +1,88 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the "isType" filter function, resulting from text such as: isType(,men), isType(creator,men) or +/// isType(creator,men,equals(hasBeard,'true')) +/// +[PublicAPI] +public class IsTypeExpression : FilterExpression +{ + public ResourceFieldChainExpression? TargetToOneRelationship { get; } + public ResourceType DerivedType { get; } + public FilterExpression? Child { get; } + + public IsTypeExpression(ResourceFieldChainExpression? targetToOneRelationship, ResourceType derivedType, FilterExpression? child) + { + ArgumentGuard.NotNull(derivedType, nameof(derivedType)); + + TargetToOneRelationship = targetToOneRelationship; + DerivedType = derivedType; + Child = child; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitIsType(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + builder.Append(Keywords.IsType); + builder.Append('('); + + if (TargetToOneRelationship != null) + { + builder.Append(toFullString ? TargetToOneRelationship.ToFullString() : TargetToOneRelationship); + } + + builder.Append(','); + builder.Append(DerivedType); + + if (Child != null) + { + builder.Append(','); + builder.Append(toFullString ? Child.ToFullString() : Child); + } + + builder.Append(')'); + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (IsTypeExpression)obj; + + return Equals(TargetToOneRelationship, other.TargetToOneRelationship) && DerivedType.Equals(other.DerivedType) && Equals(Child, other.Child); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetToOneRelationship, DerivedType, Child); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index bc5b4790ac..17c62f230f 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -28,6 +28,11 @@ public override string ToString() return $"'{value}'"; } + public override string ToFullString() + { + return ToString(); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index 0308c04de2..c8d8ffb24b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -47,12 +47,22 @@ public override TResult Accept(QueryExpressionVisitor term.ToString()))); + builder.Append(string.Join(",", Terms.Select(term => toFullString ? term.ToFullString() : term.ToString()))); builder.Append(')'); return builder.ToString(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index f528790fd3..a9c598402b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -30,12 +30,26 @@ public override TResult Accept(QueryExpressionVisitor(QueryExpressionVisitor constant.ToString())); + return string.Join(",", Elements.Select(element => element.ToString())); + } + + public override string ToFullString() + { + return string.Join(",", Elements.Select(element => element.ToFullString())); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index e442e6968d..2ff93dafe4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -9,4 +9,6 @@ namespace JsonApiDotNetCore.Queries.Expressions; public abstract class QueryExpression { public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); + + public abstract string ToFullString(); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index cd5937cd80..7051e81f73 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -91,6 +91,18 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return null; } + public override QueryExpression VisitIsType(IsTypeExpression expression, TArgument argument) + { + ResourceFieldChainExpression? newTargetToOneRelationship = expression.TargetToOneRelationship != null + ? Visit(expression.TargetToOneRelationship, argument) as ResourceFieldChainExpression + : null; + + FilterExpression? newChild = expression.Child != null ? Visit(expression.Child, argument) as FilterExpression : null; + + var newExpression = new IsTypeExpression(newTargetToOneRelationship, expression.DerivedType, newChild); + return newExpression.Equals(expression) ? expression : newExpression; + } + public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { SortElementExpression? newExpression = null; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index 7c893ba81c..7dcf44b1f4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -53,6 +53,11 @@ public virtual TResult VisitHas(HasExpression expression, TArgument argument) return DefaultVisit(expression, argument); } + public virtual TResult VisitIsType(IsTypeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + public virtual TResult VisitSortElement(SortElementExpression expression, TArgument argument) { return DefaultVisit(expression, argument); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index db0e887c09..e567da8778 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -29,6 +29,11 @@ public override string ToString() return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; } + public override string ToFullString() + { + return Scope == null ? ParameterName.ToFullString() : $"{ParameterName.ToFullString()}: {Scope.ToFullString()}"; + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index bffd8ae0ce..1d9c910955 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -38,6 +38,11 @@ public override string ToString() return $"handler('{_parameterValue}')"; } + public override string ToFullString() + { + return ToString(); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 7f19c55ba0..7decec6221 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -36,6 +36,11 @@ public override string ToString() return string.Join(".", Fields.Select(field => field.PublicName)); } + public override string ToFullString() + { + return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}")); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index 9de73655ad..78de440a42 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -35,6 +35,16 @@ public override TResult Accept(QueryExpressionVisitor child.ToString())); } + public override string ToFullString() + { + return string.Join(",", Elements.Select(child => child.ToFullString())); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index c0532070e5..bc1e611bd8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -26,7 +26,12 @@ public override TResult Accept(QueryExpressionVisitor child.PublicName).OrderBy(name => name)); + return string.Join(",", Fields.Select(field => field.PublicName).OrderBy(name => name)); + } + + public override string ToFullString() + { + return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}").OrderBy(name => name)); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index f0e434cccd..53f9ff0eb6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; public static class SparseFieldSetExpressionExtensions { public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); @@ -39,7 +39,7 @@ public static class SparseFieldSetExpressionExtensions } public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 8ec77f12fc..8e52df9b3b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -26,10 +26,20 @@ public override TResult Accept(QueryExpressionVisitor 0) { @@ -38,7 +48,7 @@ public override string ToString() builder.Append(resourceType.PublicName); builder.Append('('); - builder.Append(fields); + builder.Append(toFullString ? fieldSet.ToFullString() : fieldSet); builder.Append(')'); } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs new file mode 100644 index 0000000000..8570addf51 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -0,0 +1,75 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// Provides access to sparse fieldsets, per resource type. There's usually just a single resource type, but there can be multiple in case an endpoint +/// for an abstract resource type returns derived types. +/// +[PublicAPI] +public sealed class FieldSelection : Dictionary +{ + public bool IsEmpty => Values.All(selectors => selectors.IsEmpty); + + public ISet GetResourceTypes() + { + return Keys.ToHashSet(); + } + +#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type + public FieldSelectors GetOrCreateSelectors(ResourceType resourceType) +#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + + if (!ContainsKey(resourceType)) + { + this[resourceType] = new FieldSelectors(); + } + + return this[resourceType]; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + var writer = new IndentingStringWriter(builder); + WriteSelection(writer); + + return builder.ToString(); + } + + internal void WriteSelection(IndentingStringWriter writer) + { + using (writer.Indent()) + { + foreach (ResourceType type in GetResourceTypes()) + { + writer.WriteLine($"{nameof(FieldSelectors)}<{type.ClrType.Name}>"); + WriterSelectors(writer, type); + } + } + } + + private void WriterSelectors(IndentingStringWriter writer, ResourceType type) + { + using (writer.Indent()) + { + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in GetOrCreateSelectors(type)) + { + if (nextLayer == null) + { + writer.WriteLine(field.ToString()); + } + else + { + nextLayer.WriteLayer(writer, $"{field.PublicName}: "); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs new file mode 100644 index 0000000000..a07b4f0c79 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -0,0 +1,70 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// A data structure that contains which fields (attributes and relationships) to retrieve, or empty to retrieve all. In the case of a relationship, it +/// contains the nested query constraints. +/// +[PublicAPI] +public sealed class FieldSelectors : Dictionary +{ + public bool IsEmpty => !this.Any(); + + public bool ContainsReadOnlyAttribute + { + get + { + return this.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + } + } + + public bool ContainsOnlyRelationships + { + get + { + return this.All(selector => selector.Key is RelationshipAttribute); + } + } + + public bool ContainsField(ResourceFieldAttribute field) + { + ArgumentGuard.NotNull(field, nameof(field)); + + return ContainsKey(field); + } + + public void IncludeAttribute(AttrAttribute attribute) + { + ArgumentGuard.NotNull(attribute, nameof(attribute)); + + this[attribute] = null; + } + + public void IncludeAttributes(IEnumerable attributes) + { + ArgumentGuard.NotNull(attributes, nameof(attributes)); + + foreach (AttrAttribute attribute in attributes) + { + this[attribute] = null; + } + } + + public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer? queryLayer) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + this[relationship] = queryLayer; + } + + public void RemoveAttributes() + { + while (this.Any(pair => pair.Key is AttrAttribute)) + { + ResourceFieldAttribute field = this.First(pair => pair.Key is AttrAttribute).Key; + Remove(field); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs new file mode 100644 index 0000000000..2d5a366c28 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace JsonApiDotNetCore.Queries; + +internal sealed class IndentingStringWriter : IDisposable +{ + private readonly StringBuilder _builder; + + private int _indentDepth; + + public IndentingStringWriter(StringBuilder builder) + { + _builder = builder; + } + + public void WriteLine(string? line) + { + if (_indentDepth > 0) + { + _builder.Append(new string(' ', _indentDepth * 2)); + } + + _builder.AppendLine(line); + } + + public IndentingStringWriter Indent() + { + WriteLine("{"); + _indentDepth++; + return this; + } + + public void Dispose() + { + if (_indentDepth > 0) + { + _indentDepth--; + WriteLine("}"); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs new file mode 100644 index 0000000000..4b779d1ccd --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs @@ -0,0 +1,17 @@ +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +/// +/// Indicates how to handle derived types when resolving resource field chains. +/// +internal enum FieldChainInheritanceRequirement +{ + /// + /// Do not consider derived types when resolving attributes or relationships. + /// + Disabled, + + /// + /// Consider derived types when resolving attributes or relationships, but fail when multiple matches are found. + /// + RequireSingleMatch +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index c2b66f9063..705f057bc5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -28,15 +28,16 @@ public FilterExpression Parse(string source, ResourceType resourceTypeInScope) { ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); + return InScopeOfResourceType(resourceTypeInScope, () => + { + Tokenize(source); - FilterExpression expression = ParseFilter(); + FilterExpression expression = ParseFilter(); - AssertTokenStackIsEmpty(); + AssertTokenStackIsEmpty(); - return expression; + return expression; + }); } protected FilterExpression ParseFilter() @@ -76,6 +77,10 @@ protected FilterExpression ParseFilter() { return ParseHas(); } + case Keywords.IsType: + { + return ParseIsType(); + } } } @@ -259,13 +264,92 @@ protected HasExpression ParseHas() private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) { - ResourceType outerScopeBackup = _resourceTypeInScope!; + return InScopeOfResourceType(hasManyRelationship.RightType, ParseFilter); + } + + private IsTypeExpression ParseIsType() + { + EatText(Keywords.IsType); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + + EatSingleCharacterToken(TokenKind.Comma); + + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : _resourceTypeInScope!; + ResourceType derivedType = ParseDerivedType(baseType); + + FilterExpression? child = TryParseFilterInIsType(derivedType); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsTypeExpression(targetToOneRelationship, derivedType, child); + } + + private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + return null; + } + + return ParseFieldChain(FieldChainRequirements.EndsInToOne, "Relationship name or , expected."); + } + + private ResourceType ParseDerivedType(ResourceType baseType) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + string derivedTypeName = token.Value!; + return ResolveDerivedType(baseType, derivedTypeName); + } + + throw new QueryParseException("Resource type expected."); + } + + private ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName) + { + ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); + + if (derivedType == null) + { + throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'."); + } + + return derivedType; + } + + private ResourceType? GetDerivedType(ResourceType baseType, string publicName) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + if (derivedType.PublicName == publicName) + { + return derivedType; + } + + ResourceType? nextType = GetDerivedType(derivedType, publicName); + + if (nextType != null) + { + return nextType; + } + } + + return null; + } + + private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) + { + FilterExpression? filter = null; - _resourceTypeInScope = hasManyRelationship.RightType; + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); - FilterExpression filter = ParseFilter(); + filter = InScopeOfResourceType(derivedType, ParseFilter); + } - _resourceTypeInScope = outerScopeBackup; return filter; } @@ -341,12 +425,19 @@ protected override IImmutableList OnResolveFieldChain(st { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, + _validateSingleFieldCallback); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, + _validateSingleFieldCallback); + } + + if (chainRequirements == FieldChainRequirements.EndsInToOne) + { + return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); } if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) @@ -356,4 +447,19 @@ protected override IImmutableList OnResolveFieldChain(st throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); } + + private TResult InScopeOfResourceType(ResourceType resourceType, Func action) + { + ResourceType? backupType = _resourceTypeInScope; + + try + { + _resourceTypeInScope = resourceType; + return action(); + } + finally + { + _resourceTypeInScope = backupType; + } + } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs index 95e51dca92..a453921989 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; +using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -9,67 +11,266 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; [PublicAPI] public class IncludeParser : QueryExpressionParser { - private static readonly IncludeChainConverter IncludeChainConverter = new(); - - private readonly Action? _validateSingleRelationshipCallback; - private ResourceType? _resourceTypeInScope; - - public IncludeParser(Action? validateSingleRelationshipCallback = null) - { - _validateSingleRelationshipCallback = validateSingleRelationshipCallback; - } + private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) { ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceTypeInScope = resourceTypeInScope; - Tokenize(source); - IncludeExpression expression = ParseInclude(maximumDepth); + IncludeExpression expression = ParseInclude(resourceTypeInScope, maximumDepth); AssertTokenStackIsEmpty(); + ValidateMaximumIncludeDepth(maximumDepth, expression); return expression; } - protected IncludeExpression ParseInclude(int? maximumDepth) + protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? maximumDepth) { - ResourceFieldChainExpression firstChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); + var treeRoot = IncludeTreeNode.CreateRoot(resourceTypeInScope); - List chains = firstChain.AsList(); + ParseRelationshipChain(treeRoot); while (TokenStack.Any()) { EatSingleCharacterToken(TokenKind.Comma); - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); - chains.Add(nextChain); + ParseRelationshipChain(treeRoot); } - ValidateMaximumIncludeDepth(maximumDepth, chains); + return treeRoot.ToExpression(); + } + + private void ParseRelationshipChain(IncludeTreeNode treeRoot) + { + // A relationship name usually matches a single relationship, even when overridden in derived types. + // But in the following case, two relationships are matched on GET /shoppingBaskets?include=items: + // + // public abstract class ShoppingBasket : Identifiable + // { + // } + // + // public sealed class SilverShoppingBasket : ShoppingBasket + // { + // [HasMany] + // public ISet
Items { get; get; } + // } + // + // public sealed class PlatinumShoppingBasket : ShoppingBasket + // { + // [HasMany] + // public ISet Items { get; get; } + // } + // + // Now if the include chain has subsequent relationships, we need to scan both Items relationships for matches, + // which is why ParseRelationshipName returns a collection. + // + // The advantage of this unfolding is we don't require callers to upcast in relationship chains. The downside is + // that there's currently no way to include Products without Articles. We could add such optional upcast syntax + // in the future, if desired. + + ICollection children = ParseRelationshipName(treeRoot.AsList()); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); - return IncludeChainConverter.FromRelationshipChains(chains); + children = ParseRelationshipName(children); + } } - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IEnumerable chains) + private ICollection ParseRelationshipName(ICollection parents) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + return LookupRelationshipName(token.Value!, parents); + } + + throw new QueryParseException("Relationship name expected."); + } + + private ICollection LookupRelationshipName(string relationshipName, ICollection parents) + { + List children = new(); + HashSet relationshipsFound = new(); + + foreach (IncludeTreeNode parent in parents) + { + // Depending on the left side of the include chain, we may match relationships anywhere in the resource type hierarchy. + // This is compensated for when rendering the response, which substitutes relationships on base types with the derived ones. + IReadOnlySet relationships = parent.Relationship.RightType.GetRelationshipsInTypeOrDerived(relationshipName); + + if (relationships.Any()) + { + relationshipsFound.AddRange(relationships); + + RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => relationship.CanInclude).ToArray(); + ICollection affectedChildren = parent.EnsureChildren(relationshipsToInclude); + children.AddRange(affectedChildren); + } + } + + AssertRelationshipsFound(relationshipsFound, relationshipName, parents); + AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, parents); + + return children; + } + + private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents) + { + if (relationshipsFound.Any()) + { + return; + } + + string[] parentPaths = parents.Select(parent => parent.Path).Distinct().Where(path => path != string.Empty).ToArray(); + string path = parentPaths.Length > 0 ? $"{parentPaths[0]}.{relationshipName}" : relationshipName; + + ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray(); + + bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0); + + string message = ErrorFormatter.GetForNoneFound(ResourceFieldCategory.Relationship, relationshipName, path, parentResourceTypes, hasDerivedTypes); + throw new QueryParseException(message); + } + + private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, + ICollection parents) + { + if (relationshipsFound.All(relationship => !relationship.CanInclude)) + { + string parentPath = parents.First().Path; + ResourceType resourceType = relationshipsFound.First().LeftType; + + string message = parentPath == string.Empty + ? $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed." + : $"Including the relationship '{relationshipName}' in '{parentPath}.{relationshipName}' on '{resourceType}' is not allowed."; + + throw new InvalidQueryStringParameterException("include", "Including the requested relationship is not allowed.", message); + } + } + + private static void ValidateMaximumIncludeDepth(int? maximumDepth, IncludeExpression include) { if (maximumDepth != null) { - foreach (ResourceFieldChainExpression chain in chains) + Stack parentChain = new(); + + foreach (IncludeElementExpression element in include.Elements) { - if (chain.Fields.Count > maximumDepth) - { - string path = string.Join('.', chain.Fields.Select(field => field.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); - } + ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth.Value); } } } + private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth) + { + parentChain.Push(includeElement.Relationship); + + if (parentChain.Count > maximumDepth) + { + string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName)); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); + } + + foreach (IncludeElementExpression child in includeElement.Children) + { + ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth); + } + + parentChain.Pop(); + } + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleRelationshipCallback); + throw new NotSupportedException(); + } + + private sealed class IncludeTreeNode + { + private readonly IncludeTreeNode? _parent; + private readonly IDictionary _children = new Dictionary(); + + public RelationshipAttribute Relationship { get; } + + public string Path + { + get + { + var pathBuilder = new StringBuilder(); + IncludeTreeNode? parent = this; + + while (parent is { Relationship: not HiddenRootRelationship }) + { + pathBuilder.Insert(0, pathBuilder.Length > 0 ? $"{parent.Relationship.PublicName}." : parent.Relationship.PublicName); + parent = parent._parent; + } + + return pathBuilder.ToString(); + } + } + + private IncludeTreeNode(RelationshipAttribute relationship, IncludeTreeNode? parent) + { + Relationship = relationship; + _parent = parent; + } + + public static IncludeTreeNode CreateRoot(ResourceType resourceType) + { + var relationship = new HiddenRootRelationship(resourceType); + return new IncludeTreeNode(relationship, null); + } + + public ICollection EnsureChildren(ICollection relationships) + { + foreach (RelationshipAttribute relationship in relationships) + { + if (!_children.ContainsKey(relationship)) + { + var newChild = new IncludeTreeNode(relationship, this); + _children.Add(relationship, newChild); + } + } + + return _children.Where(pair => relationships.Contains(pair.Key)).Select(pair => pair.Value).ToList(); + } + + public IncludeExpression ToExpression() + { + IncludeElementExpression element = ToElementExpression(); + + if (element.Relationship is HiddenRootRelationship) + { + return new IncludeExpression(element.Children); + } + + return new IncludeExpression(ImmutableHashSet.Create(element)); + } + + private IncludeElementExpression ToElementExpression() + { + IImmutableSet elementChildren = _children.Values.Select(child => child.ToElementExpression()).ToImmutableHashSet(); + return new IncludeElementExpression(Relationship, elementChildren); + } + + public override string ToString() + { + IncludeExpression include = ToExpression(); + return include.ToFullString(); + } + + private sealed class HiddenRootRelationship : RelationshipAttribute + { + public HiddenRootRelationship(ResourceType rightType) + { + ArgumentGuard.NotNull(rightType, nameof(rightType)); + + RightType = rightType; + PublicName = "<>"; + } + } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs index dd0bda51b7..790f8f544d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs @@ -23,4 +23,5 @@ public static class Keywords public const string Any = "any"; public const string Count = "count"; public const string Has = "has"; + public const string IsType = "isType"; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs index 4dc7230c24..681c1dd8f4 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -31,17 +32,42 @@ protected virtual void Tokenize(string source) protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + var pathBuilder = new StringBuilder(); + EatFieldChain(pathBuilder, alternativeErrorMessage); + + IImmutableList chain = OnResolveFieldChain(pathBuilder.ToString(), chainRequirements); + + if (chain.Any()) { - IImmutableList chain = OnResolveFieldChain(token.Value!, chainRequirements); + return new ResourceFieldChainExpression(chain); + } + + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); + } - if (chain.Any()) + private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMessage) + { + while (true) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + pathBuilder.Append(token.Value); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); + pathBuilder.Append('.'); + } + else + { + return; + } + } + else { - return new ResourceFieldChainExpression(chain); + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); } } - - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); } protected CountExpression? TryParseCount() diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs index 6676cae30f..3f04ce92aa 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs @@ -14,6 +14,7 @@ public sealed class QueryTokenizer [')'] = TokenKind.CloseParen, ['['] = TokenKind.OpenBracket, [']'] = TokenKind.CloseBracket, + ['.'] = TokenKind.Period, [','] = TokenKind.Comma, [':'] = TokenKind.Colon, ['-'] = TokenKind.Minus diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs new file mode 100644 index 0000000000..6630cf2767 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +internal enum ResourceFieldCategory +{ + Field, + Attribute, + Relationship +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs new file mode 100644 index 0000000000..e15b14893a --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs @@ -0,0 +1,83 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +internal sealed class ResourceFieldChainErrorFormatter +{ + public string GetForNotFound(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, + FieldChainInheritanceRequirement inheritanceRequirement) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + builder.Append($" does not exist on resource type '{resourceType.PublicName}'"); + + if (inheritanceRequirement != FieldChainInheritanceRequirement.Disabled && resourceType.DirectlyDerivedTypes.Any()) + { + builder.Append(" or any of its derived types"); + } + + builder.Append('.'); + + return builder.ToString(); + } + + public string GetForMultipleMatches(ResourceFieldCategory category, string publicName, string path) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + builder.Append(" is defined on multiple derived types."); + + return builder.ToString(); + } + + public string GetForWrongFieldType(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, string expected) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + builder.Append($" must be {expected} on resource type '{resourceType.PublicName}'."); + + return builder.ToString(); + } + + public string GetForNoneFound(ResourceFieldCategory category, string publicName, string path, ICollection parentResourceTypes, + bool hasDerivedTypes) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + if (parentResourceTypes.Count == 1) + { + builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); + } + else + { + string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); + builder.Append($" does not exist on any of the resource types {typeNames}"); + } + + builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); + + return builder.ToString(); + } + + private static void WriteSource(ResourceFieldCategory category, string publicName, StringBuilder builder) + { + builder.Append($"{category} '{publicName}'"); + } + + private static void WritePath(string path, string publicName, StringBuilder builder) + { + if (path != publicName) + { + builder.Append($" in '{path}'"); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index 33f3643aa8..4fb2632557 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -9,6 +9,34 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; ///
internal sealed class ResourceFieldChainResolver { + private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); + + /// + /// Resolves a chain of to-one relationships. + /// author + /// + /// author.address.country + /// + /// + public IImmutableList ResolveToOneChain(ResourceType resourceType, string path, + Action? validateCallback = null) + { + ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); + ResourceType nextResourceType = resourceType; + + foreach (string publicName in path.Split(".")) + { + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); + + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); + + chainBuilder.Add(toOneRelationship); + nextResourceType = toOneRelationship.RightType; + } + + return chainBuilder.ToImmutable(); + } + /// /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments /// @@ -22,7 +50,7 @@ public IImmutableList ResolveToManyChain(ResourceType re foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); + RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); validateCallback?.Invoke(relationship, nextResourceType, path); @@ -31,7 +59,7 @@ public IImmutableList ResolveToManyChain(ResourceType re } string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); + RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); @@ -59,7 +87,7 @@ public IImmutableList ResolveRelationshipChain(ResourceT foreach (string publicName in path.Split(".")) { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); + RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); validateCallback?.Invoke(relationship, nextResourceType, path); @@ -78,7 +106,7 @@ public IImmutableList ResolveRelationshipChain(ResourceT /// name ///
public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, - Action? validateCallback = null) + FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); @@ -87,7 +115,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); validateCallback?.Invoke(toOneRelationship, nextResourceType, path); @@ -96,7 +124,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute } string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path); + AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path, inheritanceRequirement); validateCallback?.Invoke(lastAttribute, nextResourceType, path); @@ -114,7 +142,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute /// ///
public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, - Action? validateCallback = null) + FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); @@ -123,7 +151,7 @@ public IImmutableList ResolveToOneChainEndingInToMany(Re foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); validateCallback?.Invoke(toOneRelationship, nextResourceType, path); @@ -133,7 +161,7 @@ public IImmutableList ResolveToOneChainEndingInToMany(Re string lastName = publicNameParts[^1]; - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); + RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, inheritanceRequirement); validateCallback?.Invoke(toManyRelationship, nextResourceType, path); @@ -160,7 +188,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); validateCallback?.Invoke(toOneRelationship, nextResourceType, path); @@ -173,9 +201,10 @@ public IImmutableList ResolveToOneChainEndingInAttribute if (lastField is HasManyAttribute) { - throw new QueryParseException(path == lastName - ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'." - : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'."); + string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Field, lastName, path, nextResourceType, + "an attribute or a to-one relationship"); + + throw new QueryParseException(message); } validateCallback?.Invoke(lastField, nextResourceType, path); @@ -184,60 +213,75 @@ public IImmutableList ResolveToOneChainEndingInAttribute return chainBuilder.ToImmutable(); } - private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path) + private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path, + FieldChainInheritanceRequirement inheritanceRequirement) { - RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); + IReadOnlyCollection relationships = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled + ? resourceType.FindRelationshipByPublicName(publicName)?.AsArray() ?? Array.Empty() + : resourceType.GetRelationshipsInTypeOrDerived(publicName); - if (relationship == null) + if (relationships.Count == 0) { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' does not exist on resource type '{resourceType.PublicName}'." - : $"Relationship '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); + string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Relationship, publicName, path, resourceType, inheritanceRequirement); + throw new QueryParseException(message); } - return relationship; + if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && relationships.Count > 1) + { + string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Relationship, publicName, path); + throw new QueryParseException(message); + } + + return relationships.First(); } - private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path) + private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path, + FieldChainInheritanceRequirement inheritanceRequirement) { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); + RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); if (relationship is not HasManyAttribute) { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-many relationship on resource type '{resourceType.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource type '{resourceType.PublicName}'."); + string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-many relationship"); + throw new QueryParseException(message); } return relationship; } - private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path) + private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path, + FieldChainInheritanceRequirement inheritanceRequirement) { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); + RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); if (relationship is not HasOneAttribute) { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-one relationship on resource type '{resourceType.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource type '{resourceType.PublicName}'."); + string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-one relationship"); + throw new QueryParseException(message); } return relationship; } - private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path) + private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path, FieldChainInheritanceRequirement inheritanceRequirement) { - AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + IReadOnlyCollection attributes = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled + ? resourceType.FindAttributeByPublicName(publicName)?.AsArray() ?? Array.Empty() + : resourceType.GetAttributesInTypeOrDerived(publicName); - if (attribute == null) + if (attributes.Count == 0) { - throw new QueryParseException(path == publicName - ? $"Attribute '{publicName}' does not exist on resource type '{resourceType.PublicName}'." - : $"Attribute '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); + string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Attribute, publicName, path, resourceType, inheritanceRequirement); + throw new QueryParseException(message); } - return attribute; + if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && attributes.Count > 1) + { + string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Attribute, publicName, path); + throw new QueryParseException(message); + } + + return attributes.First(); } public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) @@ -246,9 +290,10 @@ public ResourceFieldAttribute GetField(string publicName, ResourceType resourceT if (field == null) { - throw new QueryParseException(path == publicName - ? $"Field '{publicName}' does not exist on resource type '{resourceType.PublicName}'." - : $"Field '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); + string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Field, publicName, path, resourceType, + FieldChainInheritanceRequirement.Disabled); + + throw new QueryParseException(message); } return field; diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs index 38a263e063..84782c2b3e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs @@ -74,14 +74,40 @@ protected SortElementExpression ParseSortElement() protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { + // An attribute or relationship name usually matches a single field, even when overridden in derived types. + // But in the following case, two attributes are matched on GET /shoppingBaskets?sort=bonusPoints: + // + // public abstract class ShoppingBasket : Identifiable + // { + // } + // + // public sealed class SilverShoppingBasket : ShoppingBasket + // { + // [Attr] + // public short BonusPoints { get; set; } + // } + // + // public sealed class PlatinumShoppingBasket : ShoppingBasket + // { + // [Attr] + // public long BonusPoints { get; set; } + // } + // + // In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends + // on which attribute is used. + // + // Because there is no syntax to pick one, we fail with an error. We could add such optional upcast syntax + // (which would be required in this case) in the future to make it work, if desired. + if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch, + _validateSingleFieldCallback); } throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs index e7c96d21d5..b23dfdfea1 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs @@ -37,14 +37,14 @@ private ResourceType ParseSparseFieldTarget() EatSingleCharacterToken(TokenKind.OpenBracket); - ResourceType resourceType = ParseResourceName(); + ResourceType resourceType = ParseResourceType(); EatSingleCharacterToken(TokenKind.CloseBracket); return resourceType; } - private ResourceType ParseResourceName() + private ResourceType ParseResourceType() { if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs index 75b6952f5a..f73cbd3418 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs @@ -6,6 +6,7 @@ public enum TokenKind CloseParen, OpenBracket, CloseBracket, + Period, Comma, Colon, Minus, diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index f7b95c3c86..00181a23a7 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -167,7 +167,7 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R Filter = GetFilter(expressionsInTopScope, resourceType), Sort = GetSort(expressionsInTopScope, resourceType), Pagination = topPagination, - Projection = GetProjectionForSparseAttributeSet(resourceType) + Selection = GetSelectionForSparseAttributeSet(resourceType) }; } @@ -203,9 +203,10 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet< foreach (IncludeElementExpression includeElement in includeElementsEvaluated) { - parentLayer.Projection ??= new Dictionary(); + parentLayer.Selection ??= new FieldSelection(); + FieldSelectors selectors = parentLayer.Selection.GetOrCreateSelectors(parentLayer.ResourceType); - if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) + if (!selectors.ContainsField(includeElement.Relationship)) { var relationshipChain = new List(parentRelationshipChain) { @@ -232,10 +233,10 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet< Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null, - Projection = GetProjectionForSparseAttributeSet(resourceType) + Selection = GetSelectionForSparseAttributeSet(resourceType) }; - parentLayer.Projection.Add(includeElement.Relationship, child); + selectors.IncludeRelationship(includeElement.Relationship, child); IImmutableSet updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); @@ -278,18 +279,15 @@ public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceTyp if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - queryLayer.Projection = new Dictionary - { - [idAttribute] = null - }; + queryLayer.Selection = new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); + selectors.IncludeAttribute(idAttribute); } - else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) + else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Selection != null) { // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. - while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute)) - { - queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute)); - } + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); + selectors.RemoveAttributes(); } return queryLayer; @@ -301,17 +299,23 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryRes ArgumentGuard.NotNull(secondaryResourceType, nameof(secondaryResourceType)); QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); - secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceType); + secondaryLayer.Selection = GetSelectionForRelationship(secondaryResourceType); secondaryLayer.Include = null; return secondaryLayer; } - private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType) +#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type + private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType) +#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type { + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(secondaryResourceType); + IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); + selectors.IncludeAttributes(secondaryAttributeSet); - return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + return selection; } /// @@ -325,12 +329,12 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, IncludeExpression? innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; - IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); - - Dictionary primaryProjection = - primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType); - primaryProjection[relationship] = secondaryLayer; + IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); + primarySelectors.IncludeAttributes(primaryAttributeSet); + primarySelectors.IncludeRelationship(relationship, secondaryLayer); FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); @@ -339,7 +343,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), - Projection = primaryProjection + Selection = primarySelection }; } @@ -387,7 +391,7 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType primaryLayer.Sort = null; primaryLayer.Pagination = null; primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter); - primaryLayer.Projection = null; + primaryLayer.Selection = null; return primaryLayer; } @@ -418,19 +422,20 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); - object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + HashSet typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(relationship.RightType); + selectors.IncludeAttribute(rightIdAttribute); + return new QueryLayer(relationship.RightType) { Include = IncludeExpression.Empty, Filter = filter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } + Selection = selection }; } @@ -442,27 +447,31 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); - object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + HashSet rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); + var secondarySelection = new FieldSelection(); + FieldSelectors secondarySelectors = secondarySelection.GetOrCreateSelectors(hasManyRelationship.RightType); + secondarySelectors.IncludeAttribute(rightIdAttribute); + + QueryLayer secondaryLayer = new(hasManyRelationship.RightType) + { + Filter = rightFilter, + Selection = secondarySelection + }; + + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(hasManyRelationship.LeftType); + primarySelectors.IncludeRelationship(hasManyRelationship, secondaryLayer); + primarySelectors.IncludeAttribute(leftIdAttribute); + return new QueryLayer(hasManyRelationship.LeftType) { Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), Filter = leftFilter, - Projection = new Dictionary - { - [hasManyRelationship] = new(hasManyRelationship.RightType) - { - Filter = rightFilter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } - }, - [leftIdAttribute] = null - } + Selection = primarySelection }; } @@ -518,22 +527,36 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection? GetProjectionForSparseAttributeSet(ResourceType resourceType) +#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type + protected virtual FieldSelection? GetSelectionForSparseAttributeSet(ResourceType resourceType) +#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceType); + var selection = new FieldSelection(); - if (!fieldSet.Any()) + HashSet resourceTypes = resourceType.GetAllConcreteDerivedTypes().ToHashSet(); + resourceTypes.Add(resourceType); + + foreach (ResourceType nextType in resourceTypes) { - return null; - } + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType); - HashSet attributeSet = fieldSet.OfType().ToHashSet(); - AttrAttribute idAttribute = GetIdAttribute(resourceType); - attributeSet.Add(idAttribute); + if (!fieldSet.Any()) + { + continue; + } + + HashSet attributeSet = fieldSet.OfType().ToHashSet(); + + FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); + selectors.IncludeAttributes(attributeSet); + + AttrAttribute idAttribute = GetIdAttribute(nextType); + selectors.IncludeAttribute(idAttribute); + } - return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + return selection.IsEmpty ? null : selection; } private static AttrAttribute GetIdAttribute(ResourceType resourceType) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs index 6384600a58..14c37bb70d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -37,44 +37,54 @@ public Expression ApplyInclude(IncludeExpression include) public override Expression VisitInclude(IncludeExpression expression, object? argument) { - Expression source = ApplyEagerLoads(_source, _resourceType.EagerLoads, null); + // De-duplicate chains coming from derived relationships. + HashSet propertyPaths = new(); + + ApplyEagerLoads(_resourceType.EagerLoads, null, propertyPaths); foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { - source = ProcessRelationshipChain(chain, source); + ProcessRelationshipChain(chain, propertyPaths); } - return source; + return ToExpression(propertyPaths); } - private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, Expression source) + private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) { string? path = null; - Expression result = source; foreach (RelationshipAttribute relationship in chain.Fields.Cast()) { path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; - result = ApplyEagerLoads(result, relationship.RightType.EagerLoads, path); + ApplyEagerLoads(relationship.RightType.EagerLoads, path, outputPropertyPaths); } - return IncludeExtensionMethodCall(result, path!); + outputPropertyPaths.Add(path!); } - private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string? pathPrefix) + private void ApplyEagerLoads(IEnumerable eagerLoads, string? pathPrefix, ISet outputPropertyPaths) { - Expression result = source; - foreach (EagerLoadAttribute eagerLoad in eagerLoads) { string path = pathPrefix != null ? $"{pathPrefix}.{eagerLoad.Property.Name}" : eagerLoad.Property.Name; - result = IncludeExtensionMethodCall(result, path); + outputPropertyPaths.Add(path); + + ApplyEagerLoads(eagerLoad.Children, path, outputPropertyPaths); + } + } + + private Expression ToExpression(HashSet propertyPaths) + { + Expression source = _source; - result = ApplyEagerLoads(result, eagerLoad.Children, path); + foreach (string propertyPath in propertyPaths) + { + source = IncludeExtensionMethodCall(source, propertyPath); } - return result; + return source; } private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs index 8be9e4263e..e5502031a3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -14,15 +14,30 @@ public sealed class LambdaScope : IDisposable public ParameterExpression Parameter { get; } public Expression Accessor { get; } - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) + private LambdaScope(LambdaParameterNameScope parameterNameScope, ParameterExpression parameter, Expression accessor) + { + _parameterNameScope = parameterNameScope; + Parameter = parameter; + Accessor = accessor; + } + + public static LambdaScope Create(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) { ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); ArgumentGuard.NotNull(elementType, nameof(elementType)); - _parameterNameScope = nameFactory.Create(elementType.Name); - Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); + LambdaParameterNameScope parameterNameScope = nameFactory.Create(elementType.Name); + ParameterExpression parameter = Expression.Parameter(elementType, parameterNameScope.Name); + Expression accessor = accessorExpression ?? parameter; + + return new LambdaScope(parameterNameScope, parameter, accessor); + } + + public LambdaScope WithAccessor(Expression accessorExpression) + { + ArgumentGuard.NotNull(accessorExpression, nameof(accessorExpression)); - Accessor = accessorExpression ?? Parameter; + return new LambdaScope(_parameterNameScope, Parameter, accessorExpression); } public void Dispose() diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs index 3ba7c5aab9..9c13a63d28 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -19,6 +19,6 @@ public LambdaScope CreateScope(Type elementType, Expression? accessorExpression { ArgumentGuard.NotNull(elementType, nameof(elementType)); - return new LambdaScope(_nameFactory, elementType, accessorExpression); + return LambdaScope.Create(_nameFactory, elementType, accessorExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index bf14c70b6d..d04ff57e9d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; @@ -9,7 +10,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; /// public abstract class QueryClauseBuilder : QueryExpressionVisitor { - protected LambdaScope LambdaScope { get; } + protected LambdaScope LambdaScope { get; private set; } protected QueryClauseBuilder(LambdaScope lambdaScope) { @@ -59,28 +60,48 @@ public override Expression VisitCount(CountExpression expression, TArgument argu } public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) - { - string[] components = expression.Fields.Select(field => field.Property.Name).ToArray(); - - return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); - } - - private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components) { MemberExpression? property = null; - foreach (string propertyName in components) + foreach (ResourceFieldAttribute field in expression.Fields) { - Type parentType = property == null ? source.Type : property.Type; + Expression parentAccessor = property ?? LambdaScope.Accessor; + Type propertyType = field.Property.DeclaringType!; + string propertyName = field.Property.Name; + + bool requiresUpCast = parentAccessor.Type != propertyType && parentAccessor.Type.IsAssignableFrom(propertyType); + Type parentType = requiresUpCast ? propertyType : parentAccessor.Type; if (parentType.GetProperty(propertyName) == null) { throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); } - property = property == null ? Expression.Property(source, propertyName) : Expression.Property(property, propertyName); + property = requiresUpCast + ? Expression.MakeMemberAccess(Expression.Convert(parentAccessor, propertyType), field.Property) + : Expression.Property(parentAccessor, propertyName); } return property!; } + + protected TResult WithLambdaScopeAccessor(Expression accessorExpression, Func action) + { + ArgumentGuard.NotNull(accessorExpression, nameof(accessorExpression)); + ArgumentGuard.NotNull(action, nameof(action)); + + LambdaScope backupScope = LambdaScope; + + try + { + using (LambdaScope = LambdaScope.WithAccessor(accessorExpression)) + { + return action(); + } + } + finally + { + LambdaScope = backupScope; + } + } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs index 7ce50c02f1..d571ac1dce 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore.Metadata; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; @@ -67,9 +66,9 @@ public virtual Expression ApplyQuery(QueryLayer layer) expression = ApplyPagination(expression, layer.Pagination); } - if (!layer.Projection.IsNullOrEmpty()) + if (layer.Selection is { IsEmpty: false }) { - expression = ApplyProjection(expression, layer.Projection, layer.ResourceType); + expression = ApplySelection(expression, layer.Selection, layer.ResourceType); } return expression; @@ -107,11 +106,11 @@ protected virtual Expression ApplyPagination(Expression source, PaginationExpres return builder.ApplySkipTake(pagination); } - protected virtual Expression ApplyProjection(Expression source, IDictionary projection, ResourceType resourceType) + protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType) { using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory); - return builder.ApplySelect(projection, resourceType); + return builder.ApplySelect(selection, resourceType); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 3931cdc180..fbf3f8ebca 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -16,6 +16,8 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; [PublicAPI] public class SelectClauseBuilder : QueryClauseBuilder { + private static readonly MethodInfo TypeGetTypeMethod = typeof(object).GetMethod("GetType")!; + private static readonly MethodInfo TypeOpEqualityMethod = typeof(Type).GetMethod("op_Equality")!; private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); @@ -42,66 +44,111 @@ public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel en _resourceFactory = resourceFactory; } - public Expression ApplySelect(IDictionary selectors, ResourceType resourceType) + public Expression ApplySelect(FieldSelection selection, ResourceType resourceType) { - ArgumentGuard.NotNull(selectors, nameof(selectors)); + ArgumentGuard.NotNull(selection, nameof(selection)); - if (!selectors.Any()) - { - return _source; - } - - Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceType, LambdaScope, false); + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, resourceType, LambdaScope, false); LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); } - private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType, - LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) + private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, + bool lambdaAccessorRequiresTestForNull) { - ICollection propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type); + IEntityType entityType = _entityModel.FindEntityType(resourceType.ClrType)!; + IEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); - MemberBinding[] propertyAssignments = - propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); - - NewExpression newExpression = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); - Expression memberInit = Expression.MemberInit(newExpression, propertyAssignments); + Expression bodyInitializer = concreteEntityTypes.Length > 1 + ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope) + : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope); if (!lambdaAccessorRequiresTestForNull) { - return memberInit; + return bodyInitializer; } - return TestForNull(lambdaScope.Accessor, memberInit); + return TestForNull(lambdaScope.Accessor, bodyInitializer); } - private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, - ResourceType resourceType, Type elementType) + private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType, + IEnumerable concreteEntityTypes, LambdaScope lambdaScope) { - var propertySelectors = new Dictionary(); + ISet resourceTypes = selection.GetResourceTypes(); + Expression rootCondition = lambdaScope.Accessor; + + foreach (IEntityType entityType in concreteEntityTypes) + { + ResourceType? resourceType = resourceTypes.SingleOrDefault(type => type.ClrType == entityType.ClrType); - // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. - bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => - selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + if (resourceType != null) + { + FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); - // Only selecting relationships implicitly means to select all attributes too. - bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); + if (!fieldSelectors.IsEmpty) + { + ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType); - if (includesReadOnlyAttribute || containsOnlyRelationships) - { - IncludeAllProperties(elementType, propertySelectors); + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)) + .Cast().ToArray(); + + NewExpression createInstance = _resourceFactory.CreateNewExpression(entityType.ClrType); + MemberInitExpression memberInit = Expression.MemberInit(createInstance, propertyAssignments); + UnaryExpression castToBaseType = Expression.Convert(memberInit, baseResourceType.ClrType); + + BinaryExpression typeCheck = CreateRuntimeTypeCheck(lambdaScope, entityType.ClrType); + rootCondition = Expression.Condition(typeCheck, castToBaseType, rootCondition); + } + } } - IncludeFieldSelection(resourceFieldSelectors, propertySelectors); + return rootCondition; + } + + private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, Type concreteClrType) + { + // Emitting "resource.GetType() == typeof(Article)" instead of "resource is Article" so we don't need to check for most-derived + // types first. This way, we can fallback to "anything else" at the end without worrying about order. + + Expression concreteTypeConstant = concreteClrType.CreateTupleAccessExpressionForConstant(typeof(Type)); + MethodCallExpression getTypeCall = Expression.Call(lambdaScope.Accessor, TypeGetTypeMethod); + return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod); + } + + private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope) + { + FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); + ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type); + + MemberBinding[] propertyAssignments = + propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); + + NewExpression createInstance = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); + return Expression.MemberInit(createInstance, propertyAssignments); + } + + private ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType) + { + var propertySelectors = new Dictionary(); + + if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) + { + // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. + // And only selecting relationships implicitly means to select all attributes too. + + IncludeAllAttributes(elementType, propertySelectors); + } + + IncludeFields(fieldSelectors, propertySelectors); IncludeEagerLoads(resourceType, propertySelectors); return propertySelectors.Values; } - private void IncludeAllProperties(Type elementType, Dictionary propertySelectors) + private void IncludeAllAttributes(Type elementType, Dictionary propertySelectors) { IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); @@ -113,10 +160,9 @@ private void IncludeAllProperties(Type elementType, Dictionary resourceFieldSelectors, - Dictionary propertySelectors) + private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors) { - foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) + foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in fieldSelectors) { var propertySelector = new PropertySelector(resourceField.Property, queryLayer); IncludeWritableProperty(propertySelector, propertySelectors); @@ -146,21 +192,26 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary Visit(expression.Child, argument)); + + return Expression.AndAlso(typeCheck, filter); + } + public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) { Expression property = Visit(expression.TargetAttribute, argument); diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index be1d657cfe..c460560a33 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -2,7 +2,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries; @@ -18,7 +17,7 @@ public sealed class QueryLayer public FilterExpression? Filter { get; set; } public SortExpression? Sort { get; set; } public PaginationExpression? Pagination { get; set; } - public IDictionary? Projection { get; set; } + public FieldSelection? Selection { get; set; } public QueryLayer(ResourceType resourceType) { @@ -32,92 +31,41 @@ public override string ToString() var builder = new StringBuilder(); var writer = new IndentingStringWriter(builder); - WriteLayer(writer, this); + WriteLayer(writer, null); return builder.ToString(); } - private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string? prefix = null) + internal void WriteLayer(IndentingStringWriter writer, string? prefix) { - writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceType.ClrType.Name}>"); + writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{ResourceType.ClrType.Name}>"); using (writer.Indent()) { - if (layer.Include != null) + if (Include != null) { - writer.WriteLine($"{nameof(Include)}: {layer.Include}"); + writer.WriteLine($"{nameof(Include)}: {Include}"); } - if (layer.Filter != null) + if (Filter != null) { - writer.WriteLine($"{nameof(Filter)}: {layer.Filter}"); + writer.WriteLine($"{nameof(Filter)}: {Filter}"); } - if (layer.Sort != null) + if (Sort != null) { - writer.WriteLine($"{nameof(Sort)}: {layer.Sort}"); + writer.WriteLine($"{nameof(Sort)}: {Sort}"); } - if (layer.Pagination != null) + if (Pagination != null) { - writer.WriteLine($"{nameof(Pagination)}: {layer.Pagination}"); + writer.WriteLine($"{nameof(Pagination)}: {Pagination}"); } - if (!layer.Projection.IsNullOrEmpty()) + if (Selection is { IsEmpty: false }) { - writer.WriteLine(nameof(Projection)); - - using (writer.Indent()) - { - foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in layer.Projection) - { - if (nextLayer == null) - { - writer.WriteLine(field.ToString()); - } - else - { - WriteLayer(writer, nextLayer, $"{field.PublicName}: "); - } - } - } - } - } - } - - private sealed class IndentingStringWriter : IDisposable - { - private readonly StringBuilder _builder; - private int _indentDepth; - - public IndentingStringWriter(StringBuilder builder) - { - _builder = builder; - } - - public void WriteLine(string? line) - { - if (_indentDepth > 0) - { - _builder.Append(new string(' ', _indentDepth * 2)); - } - - _builder.AppendLine(line); - } - - public IndentingStringWriter Indent() - { - WriteLine("{"); - _indentDepth++; - return this; - } - - public void Dispose() - { - if (_indentDepth > 0) - { - _indentDepth--; - WriteLine("}"); + writer.WriteLine(nameof(Selection)); + Selection.WriteSelection(writer); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs index 51b815c2ef..6c8bfa2934 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.QueryStrings.Internal; @@ -18,7 +17,6 @@ public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIn private readonly IncludeParser _includeParser; private IncludeExpression? _includeExpression; - private string? _lastParameterName; public bool AllowEmptyValue => false; @@ -28,18 +26,7 @@ public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph ArgumentGuard.NotNull(options, nameof(options)); _options = options; - _includeParser = new IncludeParser(ValidateSingleRelationship); - } - - protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceType resourceType, string path) - { - if (!relationship.CanInclude) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Including the requested relationship is not allowed.", - path == relationship.PublicName - ? $"Including the relationship '{relationship.PublicName}' on '{resourceType.PublicName}' is not allowed." - : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceType.PublicName}' is not allowed."); - } + _includeParser = new IncludeParser(); } /// @@ -59,8 +46,6 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; - try { _includeExpression = GetInclude(parameterValue); diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index e9a9488b7b..cadbd658a8 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -35,7 +35,7 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti ArgumentGuard.NotNull(dbContext, nameof(dbContext)); ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - Type resourceClrType = identifiable.GetType(); + Type resourceClrType = identifiable.GetClrType(); string? stringId = identifiable.StringId; EntityEntry? entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 52c07c5244..50c9e07ea3 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -155,14 +155,15 @@ protected virtual IQueryable GetAll() } /// - public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + public virtual Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { + resourceClrType, id }); - var resource = _resourceFactory.CreateInstance(); + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); resource.Id = id; return Task.FromResult(resource); @@ -305,10 +306,11 @@ protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribut } /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { + resourceFromDatabase, id }); @@ -316,7 +318,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. // If so, we'll reuse the tracked resource instead of this placeholder resource. - var placeholderResource = _resourceFactory.CreateInstance(); + TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance(); placeholderResource.Id = id; await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); @@ -413,10 +415,12 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r } /// - public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { + leftResource, leftId, rightResourceIds }); @@ -427,18 +431,22 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet(leftId, relationship, rightResourceIds, cancellationToken); + // This enables OnAddToRelationshipAsync() or OnWritingAsync() to fetch the resource, which adds it to the change tracker. + // If so, we'll reuse the tracked resource instead of this placeholder resource. + TResource leftPlaceholderResource = leftResource ?? _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; + + await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); if (rightResourceIds.Any()) { - var leftPlaceholderResource = _resourceFactory.CreateInstance(); - leftPlaceholderResource.Id = leftId; - var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource); + IEnumerable rightValueToStore = GetRightValueToStoreForAddToToMany(leftResourceTracked, relationship, rightResourceIds); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIds, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightValueToStore, cancellationToken); await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); + leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResourceTracked); await SaveChangesAsync(cancellationToken); @@ -446,6 +454,30 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIdsToAdd) + { + object? rightValueStored = relationship.GetValue(leftResource); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + HashSet rightResourceIdsStored = _collectionConverter + .ExtractResources(rightValueStored) + .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) + .ToHashSet(IdentifiableComparer.Instance); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + if (rightResourceIdsStored.Any()) + { + rightResourceIdsStored.AddRange(rightResourceIdsToAdd); + return rightResourceIdsStored; + } + + return rightResourceIdsToAdd; + } + /// public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) @@ -473,7 +505,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour // Make Entity Framework 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); + object? rightValueStored = relationship.GetValue(leftResourceTracked); // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true @@ -488,9 +520,9 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour // @formatter:wrap_chained_method_calls restore rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); - relationship.SetValue(leftResource, rightValueStored); + relationship.SetValue(leftResourceTracked, rightValueStored); - MarkRelationshipAsLoaded(leftResource, relationship); + MarkRelationshipAsLoaded(leftResourceTracked, relationship); HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 9654365121..149fef1cfb 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Repositories; public interface IResourceRepositoryAccessor { /// - /// Invokes . + /// Invokes for the specified resource type. /// Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; @@ -27,25 +27,25 @@ Task> GetAsync(QueryLayer queryLayer, Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken); /// - /// Invokes . + /// Invokes for the specified resource type. /// - Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes . + /// Invokes for the specified resource type. /// Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes . + /// Invokes for the specified resource type. /// Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes . + /// Invokes for the specified resource type. /// Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) where TResource : class, IIdentifiable; @@ -53,11 +53,11 @@ Task UpdateAsync(TResource resourceFromRequest, TResource resourceFro /// /// Invokes for the specified resource type. /// - Task DeleteAsync(TId id, CancellationToken cancellationToken) + Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes . + /// Invokes for the specified resource type. /// Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable; @@ -65,11 +65,12 @@ Task SetRelationshipAsync(TResource leftResource, object? rightValue, /// /// Invokes for the specified resource type. /// - Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes . + /// Invokes for the specified resource type. /// Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index ca0186d5b5..fb0267d18a 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -23,7 +23,7 @@ public interface IResourceWriteRepository /// /// This method can be overridden to assign resource-specific required relationships. /// - Task GetForCreateAsync(TId id, CancellationToken cancellationToken); + Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken); /// /// Creates a new resource in the underlying data store. @@ -43,7 +43,7 @@ public interface IResourceWriteRepository /// /// Deletes an existing resource from the underlying data store. /// - Task DeleteAsync(TId id, CancellationToken cancellationToken); + Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken); /// /// Performs a complete replacement of the relationship in the underlying data store. @@ -53,7 +53,7 @@ public interface IResourceWriteRepository /// /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken); + Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, CancellationToken cancellationToken); /// /// Removes resources from a to-many relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 0ba96126a1..5f00cdf08d 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -53,11 +53,11 @@ public async Task CountAsync(ResourceType resourceType, FilterExpression? f } /// - public async Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + public async Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); - return await repository.GetForCreateAsync(id, cancellationToken); + return await repository.GetForCreateAsync(resourceClrType, id, cancellationToken); } /// @@ -85,11 +85,11 @@ public async Task UpdateAsync(TResource resourceFromRequest, TResourc } /// - public async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.DeleteAsync(id, cancellationToken); + await repository.DeleteAsync(resourceFromDatabase, id, cancellationToken); } /// @@ -101,11 +101,12 @@ public async Task SetRelationshipAsync(TResource leftResource, object } /// - public async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + public async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken); + await repository.AddToToManyRelationshipAsync(leftResource, leftId, rightResourceIds, cancellationToken); } /// diff --git a/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs new file mode 100644 index 0000000000..0d294feb57 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Resources; + +/// +internal sealed class AbstractResourceWrapper : Identifiable, IAbstractResourceWrapper +{ + /// + public Type AbstractType { get; } + + public AbstractResourceWrapper(Type abstractType) + { + ArgumentGuard.NotNull(abstractType, nameof(abstractType)); + + AbstractType = abstractType; + } +} diff --git a/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs new file mode 100644 index 0000000000..7e9ac3c71d --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCore.Resources; + +/// +/// Because an instance cannot be created from an abstract resource type, this wrapper is used to preserve that information. +/// +internal interface IAbstractResourceWrapper : IIdentifiable +{ + /// + /// The abstract resource type. + /// + Type AbstractType { get; } +} diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 1a61868de5..2315ee7b1a 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -133,9 +133,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. @@ -203,9 +203,22 @@ Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasMa /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. /// /// - /// + /// /// Identifier of the left resource. The indication "left" specifies that is declared on - /// . + /// . In contrast to other relationship methods, this value is not retrieved from the underlying data store, except in + /// the following two cases: + /// + /// + /// + /// is a many-to-many relationship. This is required to prevent failure when already assigned. + /// + /// + /// + /// + /// The left resource type is part of a type hierarchy. This ensures your business logic runs against the actually stored type. + /// + /// + /// /// /// /// The to-many relationship being added to. @@ -216,7 +229,7 @@ Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasMa /// /// Propagates notification that request handling should be canceled. /// - Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken); /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index a6d229609a..edba16d045 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -70,9 +70,9 @@ public Task OnSetToManyRelationshipAsync(TResource leftResource, HasM /// /// Invokes for the specified resource. /// - public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + where TResource : class, IIdentifiable; /// /// Invokes for the specified resource. diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index a59dc8f15f..8a6bed2f91 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -22,7 +22,7 @@ public bool Equals(IIdentifiable? left, IIdentifiable? right) return true; } - if (left is null || right is null || left.GetType() != right.GetType()) + if (left is null || right is null || left.GetClrType() != right.GetClrType()) { return false; } @@ -38,6 +38,6 @@ public bool Equals(IIdentifiable? left, IIdentifiable? right) public int GetHashCode(IIdentifiable obj) { // LocalId is intentionally omitted here, it is okay for hashes to collide. - return HashCode.Combine(obj.GetType(), obj.StringId); + return HashCode.Combine(obj.GetClrType(), obj.StringId); } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 7e8064826a..8c9d4b6a36 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -11,11 +11,11 @@ public static object GetTypedId(this IIdentifiable identifiable) { ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - PropertyInfo? property = identifiable.GetType().GetProperty(IdPropertyName); + PropertyInfo? property = identifiable.GetClrType().GetProperty(IdPropertyName); if (property == null) { - throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not contain a property named '{IdPropertyName}'."); + throw new InvalidOperationException($"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{IdPropertyName}'."); } object? propertyValue = property.GetValue(identifiable); @@ -27,11 +27,18 @@ public static object GetTypedId(this IIdentifiable identifiable) if (Equals(propertyValue, defaultValue)) { - throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{IdPropertyName}' should " + + throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); } } return propertyValue!; } + + public static Type GetClrType(this IIdentifiable identifiable) + { + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + + return identifiable is IAbstractResourceWrapper abstractResource ? abstractResource.AbstractType : identifiable.GetType(); + } } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 99f575a293..dbb90bf6fe 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -1,11 +1,14 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Linq.Expressions; +using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Resources; @@ -54,8 +57,9 @@ public virtual IImmutableSet OnApplyIncludes(IImmutabl /// model.CreatedAt, ListSortDirection.Ascending), - /// (model => model.Password, ListSortDirection.Descending) + /// (blog => blog.Author.Name.LastName, ListSortDirection.Ascending), + /// (blog => blog.Posts.Count, ListSortDirection.Descending), + /// (blog => blog.Title, ListSortDirection.Ascending) /// }); /// ]]> /// @@ -64,14 +68,26 @@ protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySel ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); + var lambdaConverter = new SortExpressionLambdaConverter(ResourceGraph); - foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) + foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) { - bool isAscending = sortDirection == ListSortDirection.Ascending; - AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single(); - - var sortElement = new SortElementExpression(new ResourceFieldChainExpression(attribute), isAscending); - elementsBuilder.Add(sortElement); + try + { + SortElementExpression sortElement = lambdaConverter.FromLambda(keySelector, sortDirection); + elementsBuilder.Add(sortElement); + } + catch (InvalidOperationException exception) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid lambda expression for sorting from resource definition. " + + "It should select a property that is exposed as an attribute, or a to-many relationship followed by Count(). " + + "The property can be preceded by a path of to-one relationships. " + + "Examples: 'blog => blog.Title', 'blog => blog.Posts.Count', 'blog => blog.Author.Name.LastName'.", + Detail = $"The lambda expression '{keySelector}' is invalid. {exception.Message}" + }, exception); + } } return new SortExpression(elementsBuilder.ToImmutable()); @@ -122,7 +138,7 @@ public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasMany } /// - public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public virtual Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { return Task.CompletedTask; @@ -161,7 +177,7 @@ public virtual void OnSerialize(TResource resource) /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. /// - public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> + public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> { } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 7ccd381456..658e5e2c5b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,6 +1,6 @@ using System.Text.Json; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources; @@ -10,19 +10,19 @@ namespace JsonApiDotNetCore.Resources; public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { - private readonly ResourceType _resourceType; + private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private IDictionary? _initiallyStoredAttributeValues; private IDictionary? _requestAttributeValues; private IDictionary? _finallyStoredAttributeValues; - public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields) + public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - _resourceType = resourceGraph.GetResourceType(); + _request = request; _targetedFields = targetedFields; } @@ -31,7 +31,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); + _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } /// @@ -47,7 +47,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index a16b04cfea..9f8dfdddeb 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -104,8 +104,8 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat { ArgumentGuard.NotNull(resource, nameof(resource)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + await resourceDefinition.OnPrepareWriteAsync((dynamic)resource, writeOperation, cancellationToken); } /// @@ -116,8 +116,10 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat ArgumentGuard.NotNull(leftResource, nameof(leftResource)); ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + + return await resourceDefinition.OnSetToOneRelationshipAsync((dynamic)leftResource, hasOneRelationship, rightResourceId, writeOperation, + cancellationToken); } /// @@ -129,20 +131,20 @@ public async Task OnSetToManyRelationshipAsync(TResource leftResource ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + await resourceDefinition.OnSetToManyRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); } /// - public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public async Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + where TResource : class, IIdentifiable { ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + await resourceDefinition.OnAddToRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken); } /// @@ -154,8 +156,8 @@ public async Task OnRemoveFromRelationshipAsync(TResource leftResourc ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + await resourceDefinition.OnRemoveFromRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken); } /// @@ -164,8 +166,8 @@ public async Task OnWritingAsync(TResource resource, WriteOperationKi { ArgumentGuard.NotNull(resource, nameof(resource)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnWritingAsync(resource, writeOperation, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + await resourceDefinition.OnWritingAsync((dynamic)resource, writeOperation, cancellationToken); } /// @@ -174,8 +176,8 @@ public async Task OnWriteSucceededAsync(TResource resource, WriteOper { ArgumentGuard.NotNull(resource, nameof(resource)); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + await resourceDefinition.OnWriteSucceededAsync((dynamic)resource, writeOperation, cancellationToken); } /// @@ -183,7 +185,7 @@ public void OnDeserialize(IIdentifiable resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); resourceDefinition.OnDeserialize((dynamic)resource); } @@ -192,7 +194,7 @@ public void OnSerialize(IIdentifiable resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); resourceDefinition.OnSerialize((dynamic)resource); } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index fa3e571c7c..c276c073cd 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Reflection; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Internal; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +9,8 @@ namespace JsonApiDotNetCore.Resources; /// internal sealed class ResourceFactory : IResourceFactory { + private static readonly TypeLocator TypeLocator = new(); + private readonly IServiceProvider _serviceProvider; public ResourceFactory(IServiceProvider serviceProvider) @@ -22,9 +25,35 @@ public IIdentifiable CreateInstance(Type resourceClrType) { ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + if (!resourceClrType.IsAssignableTo(typeof(IIdentifiable))) + { + throw new InvalidOperationException($"Resource type '{resourceClrType}' does not implement IIdentifiable."); + } + + if (resourceClrType.IsAbstract) + { + return CreateWrapperForAbstractType(resourceClrType); + } + return InnerCreateInstance(resourceClrType, _serviceProvider); } + private static IIdentifiable CreateWrapperForAbstractType(Type resourceClrType) + { + ResourceDescriptor? descriptor = TypeLocator.ResolveResourceDescriptor(resourceClrType); + + if (descriptor == null) + { + throw new InvalidOperationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'."); + } + + Type wrapperClrType = typeof(AbstractResourceWrapper<>).MakeGenericType(descriptor.IdClrType); + ConstructorInfo constructor = wrapperClrType.GetConstructors().Single(); + + object resource = constructor.Invoke(ArrayFactory.Create(resourceClrType)); + return (IIdentifiable)resource; + } + /// public TResource CreateInstance() where TResource : IIdentifiable diff --git a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs new file mode 100644 index 0000000000..3736fff6d9 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs @@ -0,0 +1,167 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Resources; + +internal sealed class SortExpressionLambdaConverter +{ + private readonly IResourceGraph _resourceGraph; + private readonly IList _fields = new List(); + + public SortExpressionLambdaConverter(IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + + _resourceGraph = resourceGraph; + } + + public SortElementExpression FromLambda(Expression> keySelector, ListSortDirection sortDirection) + { + ArgumentGuard.NotNull(keySelector, nameof(keySelector)); + + _fields.Clear(); + + Expression lambdaBodyExpression = SkipConvert(keySelector.Body); + (Expression? expression, bool isCount) = TryReadCount(lambdaBodyExpression); + + if (expression != null) + { + expression = SkipConvert(expression); + expression = isCount ? ReadToManyRelationship(expression) : ReadAttribute(expression); + + while (expression != null) + { + expression = SkipConvert(expression); + + if (IsLambdaParameter(expression, keySelector.Parameters[0])) + { + return ToSortElement(isCount, sortDirection); + } + + expression = ReadToOneRelationship(expression); + } + } + + throw new InvalidOperationException($"Unsupported expression body '{lambdaBodyExpression}'."); + } + + private static Expression SkipConvert(Expression expression) + { + Expression inner = expression; + + while (true) + { + if (inner is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.TypeAs } unary) + { + inner = unary.Operand; + } + else + { + return inner; + } + } + } + + private static (Expression? innerExpression, bool isCount) TryReadCount(Expression expression) + { + if (expression is MethodCallExpression methodCallExpression && methodCallExpression.Method.Name == "Count") + { + if (methodCallExpression.Arguments.Count <= 1) + { + return (methodCallExpression.Arguments[0], true); + } + + throw new InvalidOperationException("Count method that takes a predicate is not supported."); + } + + if (expression is MemberExpression memberExpression) + { + if (memberExpression.Member.MemberType == MemberTypes.Property && memberExpression.Member.Name is "Count" or "Length") + { + if (memberExpression.Member.GetCustomAttribute() == null) + { + return (memberExpression.Expression, true); + } + } + + return (memberExpression, false); + } + + return (null, false); + } + + private Expression? ReadToManyRelationship(Expression expression) + { + if (expression is MemberExpression memberExpression) + { + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(memberExpression.Member.Name); + + if (relationship is HasManyAttribute) + { + _fields.Insert(0, relationship); + return memberExpression.Expression; + } + } + + throw new InvalidOperationException($"Expected property for JSON:API to-many relationship, but found '{expression}'."); + } + + private Expression? ReadAttribute(Expression expression) + { + if (expression is MemberExpression memberExpression) + { + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!); + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(memberExpression.Member.Name); + + if (attribute != null) + { + _fields.Insert(0, attribute); + return memberExpression.Expression; + } + } + + throw new InvalidOperationException($"Expected property for JSON:API attribute, but found '{expression}'."); + } + + private Expression? ReadToOneRelationship(Expression expression) + { + if (expression is MemberExpression memberExpression) + { + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(memberExpression.Member.Name); + + if (relationship is HasOneAttribute) + { + _fields.Insert(0, relationship); + return memberExpression.Expression; + } + } + + throw new InvalidOperationException($"Expected property for JSON:API to-one relationship, but found '{expression}'."); + } + + private static bool IsLambdaParameter(Expression expression, ParameterExpression lambdaParameter) + { + return expression is ParameterExpression parameterExpression && parameterExpression.Name == lambdaParameter.Name; + } + + private SortElementExpression ToSortElement(bool isCount, ListSortDirection sortDirection) + { + var chain = new ResourceFieldChainExpression(_fields.ToImmutableArray()); + bool isAscending = sortDirection == ListSortDirection.Ascending; + + if (isCount) + { + var countExpression = new CountExpression(chain); + return new SortElementExpression(countExpression, isAscending); + } + + return new SortElementExpression(chain, isAscending); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index f453b8dd21..d163eb56d1 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -46,6 +46,12 @@ private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityReq ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type); AssertIsKnownResourceType(resourceType, identity.Type, state); + + if (state.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + { + AssertIsNotAbstractType(resourceType, identity.Type, state); + } + AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); return resourceType; @@ -67,13 +73,21 @@ private static void AssertIsKnownResourceType([NotNull] ResourceType? resourceTy } } + private static void AssertIsNotAbstractType(ResourceType resourceType, string typeName, RequestAdapterState state) + { + if (resourceType.ClrType.IsAbstract) + { + throw new ModelConversionException(state.Position, "Abstract resource type found.", $"Resource type '{typeName}' is abstract."); + } + } + private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType? expected, string? relationshipName, RequestAdapterState state) { if (expected != null && !expected.ClrType.IsAssignableFrom(actual.ClrType)) { string message = relationshipName != null - ? $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}' of relationship '{relationshipName}'." - : $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}'."; + ? $"Type '{actual.PublicName}' is not convertible to type '{expected.PublicName}' of relationship '{relationshipName}'." + : $"Type '{actual.PublicName}' is not convertible to type '{expected.PublicName}'."; throw new ModelConversionException(state.Position, "Incompatible resource type found.", message, HttpStatusCode.Conflict); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index df75fcaa66..f18eeb8913 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -251,19 +251,19 @@ private ResourceObjectComparer() { } - public bool Equals(ResourceObject? x, ResourceObject? y) + public bool Equals(ResourceObject? left, ResourceObject? right) { - if (ReferenceEquals(x, y)) + if (ReferenceEquals(left, right)) { return true; } - if (x is null || y is null || x.GetType() != y.GetType()) + if (left is null || right is null || left.GetType() != right.GetType()) { return false; } - return x.Type == y.Type && x.Id == y.Id && x.Lid == y.Lid; + return left.Type == right.Type && left.Id == right.Id && left.Lid == right.Lid; } public int GetHashCode(ResourceObject obj) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index d881668c9f..af1a78bee1 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal; @@ -172,8 +173,11 @@ private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, Resou { if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) { - ResourceObject resourceObject = ConvertResource(resource, resourceType, kind); - treeNode = new ResourceObjectTreeNode(resource, resourceType, resourceObject); + // In case of resource inheritance, prefer the derived resource type over the base type. + ResourceType effectiveResourceType = GetEffectiveResourceType(resource, resourceType); + + ResourceObject resourceObject = ConvertResource(resource, effectiveResourceType, kind); + treeNode = new ResourceObjectTreeNode(resource, effectiveResourceType, resourceObject); _resourceToTreeNodeCache.Add(resource, treeNode); } @@ -181,6 +185,25 @@ private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, Resou return treeNode; } + private static ResourceType GetEffectiveResourceType(IIdentifiable resource, ResourceType declaredType) + { + Type runtimeResourceType = resource.GetClrType(); + + if (declaredType.ClrType == runtimeResourceType) + { + return declaredType; + } + + ResourceType? derivedType = declaredType.GetAllConcreteDerivedTypes().FirstOrDefault(type => type.ClrType == runtimeResourceType); + + if (derivedType == null) + { + throw new InvalidConfigurationException($"Type '{runtimeResourceType}' does not exist in the resource graph."); + } + + return derivedType; + } + protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) { bool isRelationship = kind == EndpointKind.Relationship; @@ -251,14 +274,25 @@ private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTre private void TraverseRelationship(RelationshipAttribute relationship, IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IncludeElementExpression includeElement, EndpointKind kind) { - object? rightValue = relationship.GetValue(leftResource); + if (!relationship.LeftType.ClrType.IsAssignableFrom(leftTreeNode.ResourceType.ClrType)) + { + // Skipping over relationship that is declared on another derived type. + return; + } + + // In case of resource inheritance, prefer the relationship on derived type over the one on base type. + RelationshipAttribute effectiveRelationship = !leftTreeNode.ResourceType.Equals(relationship.LeftType) + ? leftTreeNode.ResourceType.GetRelationshipByPropertyName(relationship.Property.Name) + : relationship; + + object? rightValue = effectiveRelationship.GetValue(leftResource); ICollection rightResources = CollectionConverter.ExtractResources(rightValue); - leftTreeNode.EnsureHasRelationship(relationship); + leftTreeNode.EnsureHasRelationship(effectiveRelationship); foreach (IIdentifiable rightResource in rightResources) { - TraverseResource(rightResource, relationship.RightType, kind, includeElement.Children, leftTreeNode, relationship); + TraverseResource(rightResource, effectiveRelationship.RightType, kind, includeElement.Children, leftTreeNode, effectiveRelationship); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 87046783c5..540ee5b73b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -196,14 +196,17 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa }); ArgumentGuard.NotNull(resource, nameof(resource)); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); TResource resourceFromRequest = resource; _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); - TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resource.Id, cancellationToken); + await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken); + + Type resourceClrType = resourceFromRequest.GetClrType(); + TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resourceClrType, resourceFromRequest.Id, cancellationToken); + AccurizeJsonApiRequest(resourceForDatabase); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); @@ -246,19 +249,44 @@ protected virtual async Task InitializeResourceAsync(TResource resourceForDataba await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); } + private async Task AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(TResource primaryResource, CancellationToken cancellationToken) + { + await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, true, cancellationToken); + } + protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) + { + await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, false, cancellationToken); + } + + private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(TResource primaryResource, bool onlyIfTypeHierarchy, + CancellationToken cancellationToken) { var missingResources = new List(); foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(primaryResource)) { - object? rightValue = relationship.GetValue(primaryResource); - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); + if (!onlyIfTypeHierarchy || relationship.RightType.IsPartOfTypeHierarchy()) + { + object? rightValue = relationship.GetValue(primaryResource); + HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + if (rightResourceIds.Any()) + { + IAsyncEnumerable missingResourcesInRelationship = + GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); - IAsyncEnumerable missingResourcesInRelationship = - GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); + await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken); - await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken); + // Some of the right-side resources from request may be typed as base types, but stored as derived types. + // Now that we've fetched them, update the request types so that resource definitions observe the actually stored types. + object? newRightValue = relationship is HasOneAttribute + ? rightResourceIds.FirstOrDefault() + : _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + + relationship.SetValue(primaryResource, newRightValue); + } + } } if (missingResources.Any()) @@ -268,20 +296,35 @@ protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource } private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, - RelationshipAttribute relationship, ICollection rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) + RelationshipAttribute relationship, ISet rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) { IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, existingRightResourceIdsQueryLayer, cancellationToken); - string[] existingResourceIds = existingResources.Select(resource => resource.StringId!).ToArray(); - - foreach (IIdentifiable rightResourceId in rightResourceIds) + foreach (IIdentifiable rightResourceId in rightResourceIds.ToArray()) { - if (!existingResourceIds.Contains(rightResourceId.StringId)) + Type rightResourceClrType = rightResourceId.GetClrType(); + IIdentifiable? existingResourceId = existingResources.FirstOrDefault(resource => resource.StringId == rightResourceId.StringId); + + if (existingResourceId != null) { - yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName, - rightResourceId.StringId!); + Type existingResourceClrType = existingResourceId.GetClrType(); + + if (rightResourceClrType.IsAssignableFrom(existingResourceClrType)) + { + if (rightResourceClrType != existingResourceClrType) + { + // PERF: As a side effect, we replace the resource base type from request with the derived type that is stored. + rightResourceIds.Remove(rightResourceId); + rightResourceIds.Add(existingResourceId); + } + + continue; + } } + + ResourceType requestResourceType = relationship.RightType.GetTypeOrDerived(rightResourceClrType); + yield return new MissingResourceInRelationship(relationship.PublicName, requestResourceType.PublicName, rightResourceId.StringId!); } } @@ -292,6 +335,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati _traceWriter.LogMethodStart(new { leftId, + relationshipName, rightResourceIds }); @@ -302,36 +346,56 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati AssertHasRelationship(_request.Relationship, relationshipName); + TResource? resourceFromDatabase = null; + if (rightResourceIds.Any() && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) { // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. - await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken); + resourceFromDatabase = await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken); + } + + if (_request.Relationship.LeftType.IsPartOfTypeHierarchy()) + { + // The left resource may be stored as a derived type. We fetch it, so we'll know the stored type, which + // enables to invoke IResourceDefinition with TResource being the stored resource type. + resourceFromDatabase ??= await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); + } + + ISet effectiveRightResourceIds = rightResourceIds; + + if (_request.Relationship.RightType.IsPartOfTypeHierarchy()) + { + // Some of the incoming right-side resources may be stored as a derived type. We fetch them, so we'll know + // the stored types, which enables to invoke resource definitions with the stored right-side resources types. + object? rightValue = await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + effectiveRightResourceIds = ((IEnumerable)rightValue!).ToHashSet(IdentifiableComparer.Instance); } try { - await _repositoryAccessor.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken); + await _repositoryAccessor.AddToToManyRelationshipAsync(resourceFromDatabase, leftId, effectiveRightResourceIds, cancellationToken); } catch (DataStoreUpdateException) { await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + await AssertRightResourcesExistAsync(effectiveRightResourceIds, cancellationToken); throw; } } - private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, - CancellationToken cancellationToken) + private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, + ISet rightResourceIds, CancellationToken cancellationToken) { - AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); - object? rightValue = _request.Relationship.GetValue(leftResource); + object? rightValue = hasManyRelationship.GetValue(leftResource); ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); rightResourceIds.ExceptWith(existingRightResourceIds); + + return leftResource; } private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, @@ -344,11 +408,12 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR return leftResource; } - protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) + protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) { AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); + HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + object? newRightValue = rightValue; if (rightResourceIds.Any()) { @@ -357,11 +422,19 @@ protected async Task AssertRightResourcesExistAsync(object? rightValue, Cancella List missingResources = await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, rightResourceIds, cancellationToken).ToListAsync(cancellationToken); + // Some of the right-side resources from request may be typed as base types, but stored as derived types. + // Now that we've fetched them, update the request types so that resource definitions observe the actually stored types. + newRightValue = _request.Relationship is HasOneAttribute + ? rightResourceIds.FirstOrDefault() + : _collectionConverter.CopyToTypedCollection(rightResourceIds, _request.Relationship.Property.PropertyType); + if (missingResources.Any()) { throw new ResourcesInRelationshipsNotFoundException(missingResources); } } + + return newRightValue; } /// @@ -380,7 +453,10 @@ protected async Task AssertRightResourcesExistAsync(object? rightValue, Cancella TResource resourceFromRequest = resource; _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); + await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); @@ -420,17 +496,24 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa AssertHasRelationship(_request.Relationship, relationshipName); + object? effectiveRightValue = _request.Relationship.RightType.IsPartOfTypeHierarchy() + // Some of the incoming right-side resources may be stored as a derived type. We fetch them, so we'll know + // the stored types, which enables to invoke resource definitions with the stored right-side resources types. + ? await AssertRightResourcesExistAsync(rightValue, cancellationToken) + : rightValue; + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); try { - await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken); + await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, effectiveRightValue, cancellationToken); } catch (DataStoreUpdateException) { - await AssertRightResourcesExistAsync(rightValue, cancellationToken); + await AssertRightResourcesExistAsync(effectiveRightValue, cancellationToken); throw; } } @@ -445,9 +528,21 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + TResource? resourceFromDatabase = null; + + if (_request.PrimaryResourceType.IsPartOfTypeHierarchy()) + { + // The resource to delete may be stored as a derived type. We fetch it, so we'll know the stored type, which + // enables to invoke IResourceDefinition with TResource being the stored resource type. + resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); + } + try { - await _repositoryAccessor.DeleteAsync(id, cancellationToken); + await _repositoryAccessor.DeleteAsync(resourceFromDatabase, id, cancellationToken); } catch (DataStoreUpdateException) { @@ -476,10 +571,14 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r var hasManyRelationship = (HasManyAttribute)_request.Relationship; TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); + + object? rightValue = await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + ISet effectiveRightResourceIds = ((IEnumerable)rightValue!).ToHashSet(IdentifiableComparer.Instance); - await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); - await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, rightResourceIds, cancellationToken); + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, effectiveRightResourceIds, cancellationToken); } protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) @@ -511,6 +610,33 @@ protected async Task GetPrimaryResourceForUpdateAsync(TId id, Cancell return resource; } + private void AccurizeJsonApiRequest(TResource resourceFromDatabase) + { + // When using resource inheritance, the stored left-side resource may be more derived than what this endpoint assumes. + // In that case, we promote data in IJsonApiRequest to better represent what is going on. + + Type storedType = resourceFromDatabase.GetClrType(); + + if (storedType != typeof(TResource)) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + ResourceType? derivedType = _request.PrimaryResourceType.GetAllConcreteDerivedTypes().FirstOrDefault(type => type.ClrType == storedType); + + if (derivedType == null) + { + throw new InvalidConfigurationException($"Type '{storedType}' does not exist in the resource graph."); + } + + var request = (JsonApiRequest)_request; + request.PrimaryResourceType = derivedType; + + if (request.Relationship != null) + { + request.Relationship = derivedType.GetRelationshipByPublicName(request.Relationship.PublicName); + } + } + } + [AssertionMethod] private void AssertPrimaryResourceExists([SysNotNull] TResource? resource) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index 465f39cee1..32024c03dd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -464,7 +464,7 @@ public async Task Cannot_create_on_relationship_type_mismatch() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 64fc6e66d3..a0ed043ae8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -648,7 +648,7 @@ public async Task Cannot_create_on_relationship_type_mismatch() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs index c50c52e08e..2302ac20f7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs @@ -19,7 +19,7 @@ public Task CountAsync(FilterExpression? filter, CancellationToken cancella throw new NotImplementedException(); } - public Task GetForCreateAsync(int id, CancellationToken cancellationToken) + public Task GetForCreateAsync(Type resourceClrType, int id, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -39,7 +39,7 @@ public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDat throw new NotImplementedException(); } - public Task DeleteAsync(int id, CancellationToken cancellationToken) + public Task DeleteAsync(Performer? resourceFromDatabase, int id, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -49,7 +49,7 @@ public Task SetRelationshipAsync(Performer leftResource, object? rightValue, Can throw new NotImplementedException(); } - public Task AddToToManyRelationshipAsync(int leftId, ISet rightResourceIds, CancellationToken cancellationToken) + public Task AddToToManyRelationshipAsync(Performer? leftResource, int leftId, ISet rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index bd97c23411..d249ec2563 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -1026,7 +1026,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 1e6787a0d7..e15e926293 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -986,7 +986,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index ee3ea791a5..abb9b423d1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -1135,7 +1135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index b66a5dfc26..13bc5a107f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -1300,7 +1300,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index 98b8892800..8ea9ac6574 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -794,7 +794,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index f8051e369c..1679ff3fa3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -1149,7 +1149,7 @@ public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index efa3813d74..b2a7f04262 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -1041,7 +1041,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); + error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index 4009f1dd82..989967bc10 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -41,9 +41,11 @@ private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) queryLayer.Sort = (SortExpression?)_writer.Visit(queryLayer.Sort, null); } - if (queryLayer.Projection != null) + if (queryLayer.Selection is { IsEmpty: false }) { - foreach (QueryLayer? nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) + foreach (QueryLayer? nextLayer in queryLayer.Selection.GetResourceTypes() + .Select(resourceType => queryLayer.Selection.GetOrCreateSelectors(resourceType)) + .SelectMany(selectors => selectors.Select(selector => selector.Value).Where(layer => layer != null))) { RecursiveRewriteFilterInLayer(nextLayer!); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs index 572a7bd3e6..888f060ca1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs @@ -17,9 +17,9 @@ public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbC { } - public override async Task GetForCreateAsync(int id, CancellationToken cancellationToken) + public override async Task GetForCreateAsync(Type resourceClrType, int id, CancellationToken cancellationToken) { - Building building = await base.GetForCreateAsync(id, cancellationToken); + Building building = await base.GetForCreateAsync(resourceClrType, id, cancellationToken); // Must ensure that an instance exists for this required relationship, so that POST Resource succeeds. building.PrimaryDoor = new Door diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs index 57098822a1..8132728c90 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs @@ -129,7 +129,7 @@ public override Task OnSetToManyRelationshipAsync(TResource leftResource, HasMan return base.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); } - public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public override Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync)) @@ -137,7 +137,7 @@ public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribu _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync); } - return base.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + return base.OnAddToRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); } public override Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs index e35efaeb52..5b4acbc311 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -569,6 +569,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index ce8e10c62e..9365ff08a0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -80,10 +80,10 @@ public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasMa } } - public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public override async Task OnAddToRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { - await base.OnAddToRelationshipAsync(groupId, hasManyRelationship, rightResourceIds, cancellationToken); + await base.OnAddToRelationshipAsync(group, hasManyRelationship, rightResourceIds, cancellationToken); if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { @@ -98,11 +98,11 @@ public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribu if (beforeUser.Group == null) { - content = new UserAddedToGroupContent(beforeUser.Id, groupId); + content = new UserAddedToGroupContent(beforeUser.Id, group.Id); } - else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) + else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) { - content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, groupId); + content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id); } if (content != null) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index a5c61d1f70..16c4394ed8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -606,6 +606,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs index a5edb3123f..a628cf9355 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -20,6 +20,9 @@ public sealed class BlogPost : Identifiable [HasOne] public WebAccount? Reviewer { get; set; } + [HasMany] + public ISet Contributors { get; set; } = new HashSet(); + [HasMany] public ISet