diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 8c08ac9c20..69e33b978d 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,13 +3,13 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
- "version": "2021.3.0",
+ "version": "2021.3.4",
"commands": [
"jb"
]
},
"regitlint": {
- "version": "6.0.6",
+ "version": "6.0.8",
"commands": [
"regitlint"
]
@@ -21,7 +21,7 @@
]
},
"dotnet-reportgenerator-globaltool": {
- "version": "5.0.0",
+ "version": "5.1.3",
"commands": [
"reportgenerator"
]
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 0d51d15e75..150c8b45df 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -88,6 +88,14 @@ public sealed class AppDbContext : DbContext
}
```
+## Creating a release (for maintainers)
+
+- Verify documentation is up-to-date
+- Bump the package version in Directory.Build.props
+- Create a GitHub release
+- Update https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb to consume the new version and release
+- Create a new branch in https://github.com/json-api-dotnet/MigrationGuide and update README.md in master
+
## Backporting and hotfixes (for maintainers)
- Checkout the version you want to apply the feature on top of and create a new branch to release the new version:
@@ -95,8 +103,8 @@ public sealed class AppDbContext : DbContext
git checkout tags/v2.5.1 -b release/2.5.2
```
- Cherrypick the merge commit: `git cherry-pick {git commit SHA}`
-- Bump the package version in the csproj
-- Make any other compatibility, documentation or tooling related changes
+- Bump the package version in Directory.Build.props
+- Make any other compatibility, documentation, or tooling related changes
- Push the branch to origin and verify the build
- Once the build is verified, create a GitHub release, tagging the release branch
- Open a PR back to master with any other additions
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}
diff --git a/CodingGuidelines.ruleset b/CodingGuidelines.ruleset
index 05545fb55c..e647ad9e58 100644
--- a/CodingGuidelines.ruleset
+++ b/CodingGuidelines.ruleset
@@ -14,6 +14,7 @@
+
@@ -21,7 +22,7 @@
-
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 2fd1da959a..1ceac951ee 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,10 +4,10 @@
6.0.*
6.0.*
6.0.*
- 4.*
- 2.*
+ 4.1.*
+ 2.14.1
6.2.*
- 5.0.0
+ 5.0.2
$(MSBuildThisFileDirectory)CodingGuidelines.ruleset
9999
enable
@@ -18,7 +18,7 @@
-
+
@@ -28,10 +28,14 @@
true
+
+ $(NoWarn);AV2210
+
+
- 3.1.0
- 4.16.1
- 17.0.0
+ 3.1.2
+ 4.17.2
+ 17.1.0
diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln
index e4b74f93aa..78464f8248 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
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.OpenApi", "src\JsonApiDotNetCore.OpenApi\JsonApiDotNetCore.OpenApi.csproj", "{71287D6F-6C3B-44B4-9FCA-E78FE3F02289}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiTests", "test\OpenApiTests\OpenApiTests.csproj", "{B693DE14-BB28-496F-AB39-B4E674ABCA80}"
@@ -276,6 +278,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
{71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|Any CPU.Build.0 = Debug|Any CPU
{71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -358,6 +372,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}
{71287D6F-6C3B-44B4-9FCA-E78FE3F02289} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF}
{B693DE14-BB28-496F-AB39-B4E674ABCA80} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
{5ADAA902-5A75-4ECB-B4B4-03291D63CE9C} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF}
diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings
index b5f9e22340..bd9975440c 100644
--- a/JsonApiDotNetCore.sln.DotSettings
+++ b/JsonApiDotNetCore.sln.DotSettings
@@ -632,8 +632,10 @@ $left$ = $right$;
$collection$.IsNullOrEmpty()
$collection$ == null || !$collection$.Any()
WARNING
+ True
True
True
+ True
True
True
True
diff --git a/README.md b/README.md
index 71e5f523e5..e2291f080e 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
@@ -79,7 +84,7 @@ See also our [versioning policy](./VERSIONING_POLICY.md).
| | | Core 3.1 | 5 |
| | | 5 | 5 |
| | | 6 | 5 |
-| v5.x | Pre-release | 6 | 6 |
+| v5.x | Stable | 6 | 6 |
## Contributing
diff --git a/ROADMAP.md b/ROADMAP.md
index 559e0bfe7d..270ae294e6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,37 +4,12 @@ This document provides an overview of the direction this project is heading and
> Disclaimer: This is an open source project. The available time of our contributors varies and therefore we do not plan release dates. This document expresses our current intent, which may change over time.
-## v4.x
+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.
-We've completed active development on v4.x, but we'll still fix important bugs or add small enhancements on request that don't require breaking changes nor lots of testing.
-
-## v5.x
-
-The need for breaking changes has blocked several efforts in the v4.x release, so now that we're starting work on v5, we're going to catch up.
-
-- [x] Remove Resource Hooks [#1025](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1025)
-- [x] Update to .NET 5 with EF Core 5 [#1026](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1026)
-- [x] Native many-to-many [#935](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/935)
-- [x] Refactorings [#1027](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1027) [#944](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/944)
-- [x] Tweak trace logging [#1033](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1033)
-- [x] Instrumentation [#1032](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1032)
-- [x] Optimized delete to-many [#1030](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1030)
-- [x] Support System.Text.Json [#664](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/664) [#999](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/999) [1077](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1077) [1078](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1078)
-- [x] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233)
-- [x] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029)
-- [x] Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010)
-- [x] Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170)
-- [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)
-
-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)
+- Optimistic concurrency [#1119](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1119)
- 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
+- Idempotency [#1132](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132)
## Feedback
diff --git a/appveyor.yml b/appveyor.yml
index 8ff835a527..445f615ef9 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -83,14 +83,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
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..e1bcb10843 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;
}
@@ -245,7 +245,7 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship
private sealed class FakeMetaBuilder : IMetaBuilder
{
- public void Add(IReadOnlyDictionary values)
+ public void Add(IDictionary values)
{
}
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": "../"
}
],
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."
});
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 fabef61b68..87542e748a 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/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": "*"
+}
diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs
index 3eb8de8fef..0bd2bd23be 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;
@@ -7,6 +8,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/ArgumentGuard.cs b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs
index 23b64d1044..be336e56a0 100644
--- a/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs
+++ b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs
@@ -18,13 +18,24 @@ public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerPar
}
[AssertionMethod]
- public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string? collectionName = null)
+ public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name)
{
NotNull(value, name);
if (!value.Any())
{
- throw new ArgumentException($"Must have one or more {collectionName ?? name}.", name);
+ throw new ArgumentException($"Must have one or more {name}.", name);
+ }
+ }
+
+ [AssertionMethod]
+ public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string collectionName)
+ {
+ NotNull(value, name);
+
+ if (!value.Any())
+ {
+ throw new ArgumentException($"Must have one or more {collectionName}.", name);
}
}
diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs
index 88037e66e3..a308607c3b 100644
--- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs
+++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs
@@ -66,9 +66,19 @@ private Type ToConcreteCollectionType(Type collectionType)
///
/// Returns a collection that contains zero, one or multiple resources, depending on the specified value.
///
- public ICollection ExtractResources(object? value)
+ public IReadOnlyCollection ExtractResources(object? value)
{
- if (value is ICollection resourceCollection)
+ if (value is List resourceList)
+ {
+ return resourceList;
+ }
+
+ if (value is HashSet resourceSet)
+ {
+ return resourceSet;
+ }
+
+ if (value is IReadOnlyCollection resourceCollection)
{
return resourceCollection;
}
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
index ce7ccd1870..515dfe8a63 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; }
@@ -75,8 +89,14 @@ public sealed class ResourceType
///
public LinkTypes RelationshipLinks { get; }
- public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes = null,
- IReadOnlyCollection? relationships = null, IReadOnlyCollection? eagerLoads = null,
+ public ResourceType(string publicName, Type clrType, Type identityClrType, LinkTypes topLevelLinks = LinkTypes.NotConfigured,
+ LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured)
+ : this(publicName, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks)
+ {
+ }
+
+ public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes,
+ IReadOnlyCollection? relationships, IReadOnlyCollection? eagerLoads,
LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured,
LinkTypes relationshipLinks = LinkTypes.NotConfigured)
{
@@ -100,6 +120,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 +204,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/ObjectExtensions.cs b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs
index bd42993803..b7fd934fbe 100644
--- a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs
+++ b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs
@@ -1,4 +1,4 @@
-#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
namespace JsonApiDotNetCore;
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.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj
index 5b48e853cb..22bbc3665a 100644
--- a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj
+++ b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj
@@ -28,7 +28,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs
index 4330a6c21c..dca0b5aef7 100644
--- a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs
+++ b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs
@@ -72,7 +72,7 @@ private static IEnumerable AddJsonApiMetadataToAction(ActionDe
}
case PrimaryRequestMetadata primaryMetadata:
{
- UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType);
+ UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType, null);
return Array.Empty();
}
case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and (RelationshipResponseMetadata or SecondaryResponseMetadata):
@@ -140,7 +140,7 @@ private static IEnumerable Expand(ActionDescriptor genericEndp
return expansion;
}
- private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type documentType, string? parameterName = null)
+ private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type documentType, string? parameterName)
{
ControllerParameterDescriptor? requestBodyDescriptor = endpoint.GetBodyParameterDescriptor();
diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj b/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj
index f3575b9189..95e9b33f7b 100644
--- a/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj
+++ b/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj
@@ -32,7 +32,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs
index eded09c490..26f5fab3f8 100644
--- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs
+++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs
@@ -3,6 +3,8 @@
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
+#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
+
namespace JsonApiDotNetCore.OpenApi.SwaggerComponents;
///
diff --git a/src/JsonApiDotNetCore/ArrayFactory.cs b/src/JsonApiDotNetCore/ArrayFactory.cs
index 2969f34d17..6ad678c64d 100644
--- a/src/JsonApiDotNetCore/ArrayFactory.cs
+++ b/src/JsonApiDotNetCore/ArrayFactory.cs
@@ -1,5 +1,5 @@
#pragma warning disable AV1008 // Class should not be static
-#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
namespace JsonApiDotNetCore;
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/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs
index 1f15ea293f..5eb09ccbc3 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs
@@ -40,7 +40,7 @@ public SetRelationshipProcessor(ISetRelationshipService service)
if (relationship is HasManyAttribute)
{
- ICollection rightResources = _collectionConverter.ExtractResources(rightValue);
+ IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue);
return rightResources.ToHashSet(IdentifiableComparer.Instance);
}
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/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/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..e07318c7f7 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)
@@ -93,8 +175,10 @@ private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType)
/// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR
/// type name.
///
+#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
public ResourceGraphBuilder Add(string? publicName = null)
where TResource : class, IIdentifiable
+#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
{
return Add(typeof(TResource), typeof(TId), publicName);
}
@@ -112,7 +196,9 @@ public ResourceGraphBuilder Add(string? publicName = null)
/// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR
/// type name.
///
+#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, string? publicName = null)
+#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
{
ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType));
@@ -221,8 +307,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 +321,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..8e5d15a7c4 100644
--- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
+++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
@@ -6,6 +6,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
+
namespace JsonApiDotNetCore.Configuration;
[PublicAPI]
@@ -16,9 +18,11 @@ public static class ServiceCollectionExtensions
///
/// Configures JsonApiDotNetCore by registering resources manually.
///
+#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null,
Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null,
ICollection? dbContextTypes = null)
+#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks
{
ArgumentGuard.NotNull(services, nameof(services));
@@ -111,7 +115,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/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))
{
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/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs
index fde552ec8f..44cc2955d9 100644
--- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs
+++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs
@@ -32,7 +32,13 @@ static CascadingCodeTimer()
}
///
- public IDisposable Measure(string name, bool excludeInRelativeCost = false)
+ public IDisposable Measure(string name)
+ {
+ return Measure(name, false);
+ }
+
+ ///
+ public IDisposable Measure(string name, bool excludeInRelativeCost)
{
MeasureScope childScope = CreateChildScope(name, excludeInRelativeCost);
_activeScopeStack.Push(childScope);
diff --git a/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs
index 928017b7e0..a50c25eaba 100644
--- a/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs
+++ b/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs
@@ -11,7 +11,12 @@ private DisabledCodeTimer()
{
}
- public IDisposable Measure(string name, bool excludeInRelativeCost = false)
+ public IDisposable Measure(string name)
+ {
+ return this;
+ }
+
+ public IDisposable Measure(string name, bool excludeInRelativeCost)
{
return this;
}
diff --git a/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs
index 23ff4ac605..ff219b5a37 100644
--- a/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs
+++ b/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs
@@ -5,6 +5,15 @@ namespace JsonApiDotNetCore.Diagnostics;
///
public interface ICodeTimer : IDisposable
{
+ ///
+ /// Starts recording the duration of a code block, while including this measurement in calculated percentages. Wrap this call in a using
+ /// statement, so the recording stops when the return value goes out of scope.
+ ///
+ ///
+ /// Description of what is being recorded.
+ ///
+ IDisposable Measure(string name);
+
///
/// Starts recording the duration of a code block. Wrap this call in a using statement, so the recording stops when the return value goes out of
/// scope.
@@ -13,9 +22,9 @@ public interface ICodeTimer : IDisposable
/// Description of what is being recorded.
///
///
- /// When set, indicates to exclude this measurement in calculated percentages. false by default.
+ /// When set, indicates to exclude this measurement in calculated percentages.
///
- IDisposable Measure(string name, bool excludeInRelativeCost = false);
+ IDisposable Measure(string name, bool excludeInRelativeCost);
///
/// Returns intermediate or final results.
diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
index cc91d0f324..e401db38fa 100644
--- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
+++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
@@ -39,8 +39,8 @@
-
-
+
+
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
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/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs
index 14b6289b83..bff295acae 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs
@@ -8,9 +8,14 @@ public sealed class Token
public TokenKind Kind { get; }
public string? Value { get; }
- public Token(TokenKind kind, string? value = null)
+ public Token(TokenKind kind)
{
Kind = kind;
+ }
+
+ public Token(TokenKind kind, string value)
+ : this(kind)
+ {
Value = value;
}
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..40af882044 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,21 @@ 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)
+ private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType)
{
+ 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 +327,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 +341,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer,
{
Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship),
Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter),
- Projection = primaryProjection
+ Selection = primarySelection
};
}
@@ -387,7 +389,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;
}
@@ -400,7 +402,7 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType
foreach (RelationshipAttribute relationship in _targetedFields.Relationships)
{
object? rightValue = relationship.GetValue(primaryResource);
- ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue);
+ HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance);
if (rightResourceIds.Any())
{
@@ -418,19 +420,20 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati
AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType);
- object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray();
+ HashSet
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..690c49de24 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();
+ IReadOnlySet 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..7384cec693 100644
--- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
+++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
@@ -105,7 +105,9 @@ public virtual async Task CountAsync(FilterExpression? filter, Cancellation
}
}
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer)
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
{
_traceWriter.LogMethodStart(new
{
@@ -149,20 +151,23 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer)
}
}
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
protected virtual IQueryable GetAll()
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
{
return _dbContext.Set();
}
///
- 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);
@@ -220,12 +225,12 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r
if (relationship is HasManyAttribute hasManyRelationship)
{
- HashSet rightResourceIdSet = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance);
+ HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance);
- await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIdSet, writeOperation,
+ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation,
cancellationToken);
- return rightResourceIdSet;
+ return rightResourceIds;
}
return rightValue;
@@ -305,10 +310,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 +322,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 +419,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 +435,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 +458,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 +509,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 +524,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);
@@ -540,7 +576,7 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
return null;
}
- ICollection rightResources = _collectionConverter.ExtractResources(rightValue);
+ IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue);
IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray();
return rightValue is IEnumerable
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..9af79831b2 100644
--- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
+++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
@@ -108,14 +108,16 @@ public interface IResourceDefinition
/// }
/// ]]>
///
-#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters();
-#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
///
/// Enables to add JSON:API meta information, specific to this resource.
///
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
IDictionary? GetMeta(TResource resource);
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
///
/// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request.
@@ -133,9 +135,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 +205,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 +231,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..55f32ead40 100644
--- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs
+++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs
@@ -45,7 +45,9 @@ public interface IResourceDefinitionAccessor
///
/// Invokes for the specified resource.
///
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance);
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
///
/// Invokes for the specified resource.
@@ -70,9 +72,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/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs
index 91f2553fd2..d2fa2c0d3e 100644
--- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs
+++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs
@@ -39,7 +39,9 @@ public OperationContainer WithResource(IIdentifiable resource)
return new OperationContainer(resource, TargetedFields, Request);
}
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
public ISet GetSecondaryResources()
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
{
var secondaryResources = new HashSet(IdentifiableComparer.Instance);
@@ -54,7 +56,7 @@ public ISet GetSecondaryResources()
private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources)
{
object? rightValue = relationship.GetValue(Resource);
- ICollection rightResources = CollectionConverter.ExtractResources(rightValue);
+ IReadOnlyCollection rightResources = CollectionConverter.ExtractResources(rightValue);
secondaryResources.AddRange(rightResources);
}
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..a2371419b6
--- /dev/null
+++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs
@@ -0,0 +1,170 @@
+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 = memberExpression.Member.Name == nameof(Identifiable.Id) && memberExpression.Expression != null
+ ? _resourceGraph.GetResourceType(memberExpression.Expression.Type)
+ : _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/DocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs
index 6d26125666..8a50db9fec 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs
@@ -21,7 +21,7 @@ public DocumentInOperationsRequestAdapter(IJsonApiOptions options, IAtomicOperat
}
///
- public IList Convert(Document document, RequestAdapterState state)
+ public IReadOnlyList Convert(Document document, RequestAdapterState state)
{
ArgumentGuard.NotNull(state, nameof(state));
AssertHasOperations(document.Operations, state);
@@ -50,7 +50,7 @@ private void AssertMaxOperationsNotExceeded(ICollection
}
}
- private IList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state)
+ private IReadOnlyList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state)
{
var operations = new List();
int operationIndex = 0;
diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs
index 228abbf3ce..b4e929cba3 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs
@@ -11,5 +11,5 @@ public interface IDocumentInOperationsRequestAdapter
///
/// Validates and converts the specified .
///
- IList Convert(Document document, RequestAdapterState state);
+ IReadOnlyList Convert(Document document, RequestAdapterState state);
}
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/EmptyResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs
index 77b2058796..dfdb20bde1 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs
@@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Response;
public sealed class EmptyResponseMeta : IResponseMeta
{
///
- public IReadOnlyDictionary? GetMeta()
+ public IDictionary? GetMeta()
{
return null;
}
diff --git a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs
index e804179505..1c3916c404 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs
@@ -12,10 +12,12 @@ public interface IMetaBuilder
/// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will
/// overwrite the existing one.
///
- void Add(IReadOnlyDictionary values);
+ void Add(IDictionary values);
///
/// Builds the top-level meta data object.
///
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
IDictionary? Build();
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
}
diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs
index f3035cfa4c..2e17ec9bdb 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs
@@ -12,5 +12,7 @@ public interface IResponseMeta
///
/// Gets the global top-level JSON:API meta information to add to the response.
///
- IReadOnlyDictionary? GetMeta();
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
+ IDictionary? GetMeta();
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
}
diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
index 38e63ea9cb..0bad02066b 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
@@ -312,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/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs
index d250501d60..91ec62387c 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs
@@ -26,7 +26,7 @@ public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options
}
///
- public void Add(IReadOnlyDictionary values)
+ public void Add(IDictionary values)
{
ArgumentGuard.NotNull(values, nameof(values));
@@ -43,7 +43,7 @@ public void Add(IReadOnlyDictionary values)
_meta.Add(key, _paginationContext.TotalResourceCount);
}
- IReadOnlyDictionary? extraMeta = _responseMeta.GetMeta();
+ IDictionary? extraMeta = _responseMeta.GetMeta();
if (extraMeta != null)
{
diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
index df75fcaa66..aed19d9097 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
@@ -88,7 +88,7 @@ public void AttachRelationshipChild(RelationshipAttribute relationship, Resource
///
/// Recursively walks the tree and returns the set of unique nodes. Uses relationship declaration order.
///
- public ISet GetUniqueNodes()
+ public IReadOnlySet GetUniqueNodes()
{
AssertIsTreeRoot();
@@ -148,7 +148,7 @@ private static void VisitRelationshipChildInSubtree(HashSet? GetRightNodesInRelationship(RelationshipAttribute relationship)
+ public IReadOnlySet? GetRightNodesInRelationship(RelationshipAttribute relationship)
{
return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes)
? rightNodes
@@ -158,7 +158,7 @@ private static void VisitRelationshipChildInSubtree(HashSet
/// Provides the value for 'data' in the response body. Uses relationship declaration order.
///
- public IList GetResponseData()
+ public IReadOnlyList GetResponseData()
{
AssertIsTreeRoot();
@@ -168,7 +168,9 @@ public IList GetResponseData()
///
/// Provides the value for 'included' in the response body. Uses relationship declaration order.
///
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
public IList GetResponseIncluded()
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
{
AssertIsTreeRoot();
@@ -251,19 +253,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..223166e59e 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;
@@ -79,7 +80,7 @@ public Document Convert(object? model)
PopulateRelationshipsInTree(rootNode, _request.Kind);
- IEnumerable resourceObjects = rootNode.GetResponseData();
+ IReadOnlyList resourceObjects = rootNode.GetResponseData();
document.Data = new SingleOrManyData(resourceObjects);
}
else if (model is IIdentifiable resource)
@@ -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;
@@ -208,7 +231,9 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc
return resourceObject;
}
+#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection
protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType,
+#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection
IImmutableSet fieldSet)
{
var attrMap = new Dictionary(resourceType.Attributes.Count);
@@ -251,14 +276,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);
- ICollection rightResources = CollectionConverter.ExtractResources(rightValue);
+ 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);
+ IReadOnlyCollection 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);
}
}
@@ -306,7 +342,7 @@ private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNod
private static SingleOrManyData GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship)
{
- ISet? rightNodes = treeNode.GetRightNodesInRelationship(relationship);
+ IReadOnlySet? rightNodes = treeNode.GetRightNodesInRelationship(relationship);
if (rightNodes != null)
{
diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs
index 87046783c5..0cca8b92b9 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);
- ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue);
+ object? rightValue = hasManyRelationship.GetValue(leftResource);
+ IReadOnlyCollection 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/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs
index 5053d17e09..2af39b3d26 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs
@@ -45,7 +45,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId);
@@ -71,7 +71,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId);
@@ -98,7 +98,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[1].StringId);
@@ -125,7 +125,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.ManyValue.ShouldHaveCount(2);
responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[0].StringId);
@@ -155,7 +155,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Id.Should().Be(station.StringId);
@@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Id.Should().Be(station.StringId);
@@ -216,7 +216,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Id.Should().Be(comment.AppliesTo.StringId);
@@ -243,7 +243,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId);
@@ -270,7 +270,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt!.Value;
@@ -303,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
// Assert
- httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId);
@@ -334,7 +334,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync