diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 40d3ba4893..ad89a48ac6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,13 +3,13 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2023.1.0", + "version": "2023.1.2", "commands": [ "jb" ] }, "regitlint": { - "version": "6.3.10", + "version": "6.3.11", "commands": [ "regitlint" ] @@ -21,13 +21,13 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.19", + "version": "5.1.20", "commands": [ "reportgenerator" ] }, "docfx": { - "version": "2.62.2", + "version": "2.67.1", "commands": [ "docfx" ] diff --git a/.editorconfig b/.editorconfig index 86cbbc3700..5a036d1797 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.{config,csproj,css,js,json,props,ruleset,xslt}] +[*.{config,csproj,css,js,json,props,ruleset,xslt,html}] indent_size = 2 [*.{cs}] diff --git a/Directory.Build.props b/Directory.Build.props index 3a086c9c70..68a1b56b3f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,13 +4,13 @@ 6.0.* 7.0.* 7.0.* - 4.5.* + 4.6.* 2.14.1 - 6.4.* - 13.16.* - 6.0.* + 6.5.* + 13.19.* + 7.0.* 13.0.* - 5.2.1 + 5.3.1 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 enable @@ -20,7 +20,7 @@ - + @@ -37,8 +37,8 @@ - 3.2.* + 6.0.* 4.18.* - 17.5.* + 17.6.* diff --git a/PackageReadme.md b/PackageReadme.md new file mode 100644 index 0000000000..f57386041b --- /dev/null +++ b/PackageReadme.md @@ -0,0 +1,5 @@ +A framework for building [JSON:API](http://jsonapi.org/) compliant REST APIs using .NET Core and Entity Framework Core. Includes support for [Atomic Operations](https://jsonapi.org/ext/atomic/). + +The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection, making extensibility incredibly easy. + +For more information, visit [www.jsonapi.net](https://www.jsonapi.net/). diff --git a/README.md b/README.md index 26432ee909..b3ffb5db90 100644 --- a/README.md +++ b/README.md @@ -130,3 +130,8 @@ Alternatively, to build and validate the code, run all tests, generate code cove ```bash pwsh Build.ps1 ``` + +## Sponsors + +JetBrains Logo   +Araxis Logo diff --git a/appveyor.yml b/appveyor.yml index 3b17a6ae0f..cf42be3427 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -67,6 +67,7 @@ for: Copy-Item CNAME _site/CNAME Copy-Item home/*.html _site/ Copy-Item home/*.ico _site/ + New-Item -Force _site/styles -ItemType Directory | Out-Null Copy-Item -Recurse home/assets/* _site/styles/ CD _site git add -A 2>&1 diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index 4218c2e3dc..2f466a3fcb 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -4,8 +4,8 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging.Abstractions; @@ -37,11 +37,23 @@ public QueryStringParserBenchmarks() var resourceFactory = new ResourceFactory(new ServiceContainer()); - var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); - var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options); - var sortReader = new SortQueryStringParameterReader(request, resourceGraph); - var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph); - var paginationReader = new PaginationQueryStringParameterReader(request, resourceGraph, options); + var includeParser = new IncludeParser(options); + var includeReader = new IncludeQueryStringParameterReader(includeParser, request, resourceGraph); + + var filterScopeParser = new QueryStringParameterScopeParser(); + var filterValueParser = new FilterParser(resourceFactory); + var filterReader = new FilterQueryStringParameterReader(filterScopeParser, filterValueParser, request, resourceGraph, options); + + var sortScopeParser = new QueryStringParameterScopeParser(); + var sortValueParser = new SortParser(); + var sortReader = new SortQueryStringParameterReader(sortScopeParser, sortValueParser, request, resourceGraph); + + var sparseFieldSetScopeParser = new SparseFieldTypeParser(resourceGraph); + var sparseFieldSetValueParser = new SparseFieldSetParser(); + var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(sparseFieldSetScopeParser, sparseFieldSetValueParser, request, resourceGraph); + + var paginationParser = new PaginationParser(); + var paginationReader = new PaginationQueryStringParameterReader(paginationParser, request, resourceGraph, options); IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader); diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index 5bb97a3156..458c4eecae 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index 3f9efcc11d..a2d76b87b1 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index d9cfefd0b6..4b16afb393 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -5,7 +5,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Response; diff --git a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs index 3de20cb7fd..a6f7ca1789 100644 --- a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs +++ b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,6 +14,7 @@ namespace Benchmarks.Tools; internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor { bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IQueryableBuilder QueryableBuilder => throw new NotImplementedException(); public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index bdd13d16b8..d65826687a 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -37,11 +37,15 @@ if (-Not $NoBuild -Or -Not (Test-Path -Path _site)) { Invoke-Expression ./generate-examples.ps1 } +dotnet tool restore +VerifySuccessExitCode + dotnet docfx ./docfx.json VerifySuccessExitCode Copy-Item -Force home/*.html _site/ Copy-Item -Force home/*.ico _site/ +New-Item -Force _site/styles -ItemType Directory | Out-Null Copy-Item -Force -Recurse home/assets/* _site/styles/ cd _site diff --git a/docs/docfx.json b/docs/docfx.json index 7fdafa0fe5..1d0e192ac2 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -40,7 +40,7 @@ "dest": "_site", "globalMetadataFiles": [], "fileMetadataFiles": [], - "template": [ "default" ], + "template": [ "default", "modern" ], "postProcessors": [], "noLangKeyword": false, "keepFileLink": false, diff --git a/docs/home/assets/fonts/icofont.eot b/docs/home/assets/fonts/icofont.eot new file mode 100644 index 0000000000..47790c2f50 Binary files /dev/null and b/docs/home/assets/fonts/icofont.eot differ diff --git a/docs/home/assets/fonts/icofont.svg b/docs/home/assets/fonts/icofont.svg new file mode 100644 index 0000000000..685f2e87d3 --- /dev/null +++ b/docs/home/assets/fonts/icofont.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/home/assets/fonts/icofont.ttf b/docs/home/assets/fonts/icofont.ttf new file mode 100644 index 0000000000..9d3b07d153 Binary files /dev/null and b/docs/home/assets/fonts/icofont.ttf differ diff --git a/docs/home/assets/fonts/icofont.woff b/docs/home/assets/fonts/icofont.woff new file mode 100644 index 0000000000..8f9f5aef91 Binary files /dev/null and b/docs/home/assets/fonts/icofont.woff differ diff --git a/docs/home/assets/fonts/icofont.woff2 b/docs/home/assets/fonts/icofont.woff2 new file mode 100644 index 0000000000..a5db6146e1 Binary files /dev/null and b/docs/home/assets/fonts/icofont.woff2 differ diff --git a/docs/home/assets/home.css b/docs/home/assets/home.css index bfd6f96e06..273efe261b 100644 --- a/docs/home/assets/home.css +++ b/docs/home/assets/home.css @@ -95,7 +95,6 @@ h1, h2, h3, h4, h5, h6, .font-primary { margin-top: 72px; } - /*-------------------------------------------------------------- # Hero Section --------------------------------------------------------------*/ @@ -300,12 +299,6 @@ section { .breadcrumbs ol li { display: inline-block; } - - -} - -div[feature]:hover { - cursor: pointer; } /*-------------------------------------------------------------- @@ -401,6 +394,34 @@ div[feature]:hover { margin-bottom: 0; } +div[feature]:hover { + cursor: pointer; +} + +/*-------------------------------------------------------------- +# Sponsors +--------------------------------------------------------------*/ +.sponsors .icon-box { + padding: 30px; + position: relative; + overflow: hidden; + margin: 0 0 40px 0; + background: #fff; + box-shadow: 0 10px 29px 0 rgba(68, 88, 144, 0.1); + transition: all 0.3s ease-in-out; + border-radius: 15px; + text-align: center; + border-bottom: 3px solid #fff; +} + +.sponsors .icon-box:hover { + transform: translateY(-5px); + border-color: #ef7f4d; +} + +div[sponsor]:hover { + cursor: pointer; +} /*-------------------------------------------------------------- # Footer diff --git a/docs/home/assets/home.js b/docs/home/assets/home.js index cb8ac539bd..ed6571bf23 100644 --- a/docs/home/assets/home.js +++ b/docs/home/assets/home.js @@ -38,7 +38,6 @@ } }); - // Feature panels linking $('div[feature]#filter').on('click', () => navigateTo('usage/reading/filtering.html')); $('div[feature]#sort').on('click', () => navigateTo('usage/reading/sorting.html')); @@ -49,13 +48,19 @@ $('div[feature]#validation').on('click', () => navigateTo('usage/options.html#enable-modelstate-validation')); $('div[feature]#customizable').on('click', () => navigateTo('usage/extensibility/resource-definitions.html')); - const navigateTo = (url) => { - if (!window.getSelection().toString()){ + if (!window.getSelection().toString()) { window.location = url; } } + // Sponsor panels linking + $('div[sponsor]#jetbrains').on('click', () => navigateExternalTo('https://jb.gg/OpenSourceSupport')); + $('div[sponsor]#araxis').on('click', () => navigateExternalTo('https://www.araxis.com/buy/open-source')); + + const navigateExternalTo = (url) => { + window.open(url, "_blank"); + } hljs.initHighlightingOnLoad() diff --git a/docs/home/assets/icofont.min.css b/docs/home/assets/icofont.min.css new file mode 100644 index 0000000000..58ff34474c --- /dev/null +++ b/docs/home/assets/icofont.min.css @@ -0,0 +1,7 @@ +/*! +* @package IcoFont +* @version 1.0.1 +* @author IcoFont https://icofont.com +* @copyright Copyright (c) 2015 - 2023 IcoFont +* @license - https://icofont.com/license/ +*/@font-face{font-family:IcoFont;font-weight:400;font-style:Regular;src:url(fonts/icofont.woff2) format("woff2"),url(fonts/icofont.woff) format("woff")}[class*=" icofont-"],[class^=icofont-]{font-family:IcoFont!important;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;white-space:nowrap;word-wrap:normal;direction:ltr;line-height:1;-webkit-font-feature-settings:"liga";-webkit-font-smoothing:antialiased}.icofont-simple-up:before{content:"\eab9"}.icofont-xs{font-size:.5em}.icofont-sm{font-size:.75em}.icofont-md{font-size:1.25em}.icofont-lg{font-size:1.5em}.icofont-1x{font-size:1em}.icofont-2x{font-size:2em}.icofont-3x{font-size:3em}.icofont-4x{font-size:4em}.icofont-5x{font-size:5em}.icofont-6x{font-size:6em}.icofont-7x{font-size:7em}.icofont-8x{font-size:8em}.icofont-9x{font-size:9em}.icofont-10x{font-size:10em}.icofont-fw{text-align:center;width:1.25em}.icofont-ul{list-style-type:none;padding-left:0;margin-left:0}.icofont-ul>li{position:relative;line-height:2em}.icofont-ul>li .icofont{display:inline-block;vertical-align:middle}.icofont-border{border:solid .08em #f1f1f1;border-radius:.1em;padding:.2em .25em .15em}.icofont-pull-left{float:left}.icofont-pull-right{float:right}.icofont.icofont-pull-left{margin-right:.3em}.icofont.icofont-pull-right{margin-left:.3em}.icofont-spin{-webkit-animation:icofont-spin 2s infinite linear;animation:icofont-spin 2s infinite linear;display:inline-block}.icofont-pulse{-webkit-animation:icofont-spin 1s infinite steps(8);animation:icofont-spin 1s infinite steps(8);display:inline-block}@-webkit-keyframes icofont-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes icofont-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.icofont-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.icofont-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.icofont-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.icofont-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.icofont-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.icofont-flip-horizontal.icofont-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .icofont-flip-horizontal,:root .icofont-flip-vertical,:root .icofont-rotate-180,:root .icofont-rotate-270,:root .icofont-rotate-90{-webkit-filter:none;filter:none;display:inline-block}.icofont-inverse{color:#fff}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto} \ No newline at end of file diff --git a/docs/home/index.html b/docs/home/index.html index 7f01a30e32..2c3e57849b 100644 --- a/docs/home/index.html +++ b/docs/home/index.html @@ -1,147 +1,147 @@ - - - - JsonApiDotNetCore documentation - - - - - - - - - - - - - -
-
+ + + + JsonApiDotNetCore documentation + + + + + + + + + + + + + +
+
+
+
+

JsonApiDotNetCore

+

A framework for building JSON:API compliant REST APIs using .NET Core and Entity Framework Core. Includes support for Atomic Operations.

+ Read more + Getting started + Contribute on GitHub +
+
+ project logo +
+
+
+
+
+
+
+
+
+ people working at desk +
+
+

Objectives

+

+ The goal of this library is to simplify the development of APIs that leverage the full range of features provided by the JSON:API specification. + You just need to focus on defining the resources and implementing your custom business logic. +

-
-

JsonApiDotNetCore

-

A framework for building JSON:API compliant REST APIs using .NET Core and Entity Framework Core. Includes support for Atomic Operations.

- Read more - Getting started - Contribute on GitHub -
-
- project logo -
+
+ +

Eliminate boilerplate

+

We strive to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination.

+
+
+ +

Extensibility

+

This library has been designed around dependency injection, making extensibility incredibly easy.

+
+
+
+
+
+
+
+
+
+

Features

+

The following features are supported, from HTTP all the way down to the database

+
+
+
+
+
+

Filtering

+

Perform compound filtering using the filter query string parameter

-
-
-
-
-
-
-
- people working at desk -
-
-

Objectives

-

- The goal of this library is to simplify the development of APIs that leverage the full range of features provided by the JSON:API specification. - You just need to focus on defining the resources and implementing your custom business logic. -

-
-
- -

Eliminate boilerplate

-

We strive to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination.

-
-
- -

Extensibility

-

This library has been designed around dependency injection, making extensibility incredibly easy.

-
-
-
-
+
+
+
+
+

Sorting

+

Order resources on one or multiple attributes using the sort query string parameter

-
-
-
-
-

Features

-

The following features are supported, from HTTP all the way down to the database

-
-
-
-
-
-

Filtering

-

Perform compound filtering using the filter query string parameter

-
-
-
-
-
-

Sorting

-

Order resources on one or multiple attributes using the sort query string parameter

-
-
- -
-
-
-

Sparse fieldset selection

-

Get only the data that you need using the fields query string parameter

-
-
-
-
-
-
-
-

Relationship inclusion

-

Side-load related resources of nested relationships using the include query string parameter

-
-
-
-
-
-

Security

-

Configure permissions, such as view/create/change/sort/filter of attributes and relationships

-
-
-
-
-
-

Validation

-

Validate incoming requests using built-in ASP.NET Core ModelState validation, which works seamlessly with partial updates

-
-
-
-
-
-

Customizable

-

Use various extensibility points to intercept and run custom code, besides just model annotations

-
-
-
-
-
-
-
-
-

Example usage

-

Expose resources with attributes and relationships

+
+
+
+
+
+

Example usage

+

Expose resources with attributes and relationships

+
+
+
+
+
+

Resource

+
 #nullable enable
 
 public class Article : Identifiable<long>
@@ -172,31 +172,26 @@ 

Resource

[HasMany] public ICollection<Tag> Tags { get; set; } = new HashSet<Tag>(); }
-
-
-
+
-
-
-
-
-

Request

-
-
-GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
-
-                     
-
-
+
+
+
+
+
+
+

Request

+
GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
-
-
-
-
-

Response

-
-
-{
+          
+
+
+
+
+
+

Response

+
+{
   "meta": {
     "totalResources": 1
   },
@@ -252,31 +247,54 @@ 

Response

] }
-
-
-
+
-
-
-
- +
+
+
+
+

Sponsors

+
+
+
+
+
+ JetBrains Logo
- - - - - - - - - - - +
+
+
+ Araxis Logo +
+
+
+
+
+
+
+ +
+ + + + + + + + + + diff --git a/docs/internals/queries.md b/docs/internals/queries.md index 76f062c233..198a1659a2 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -4,8 +4,9 @@ _since v4.0_ The query pipeline roughly looks like this: -``` -HTTP --[ASP.NET]--> QueryString --[JADNC:QueryStringParameterReader]--> QueryExpression[] --[JADNC:ResourceService]--> QueryLayer --[JADNC:Repository]--> IQueryable --[Entity Framework Core]--> SQL +```mermaid +flowchart TB +A[HTTP] -->|ASP.NET| B(QueryString) -->|JADNC:QueryStringParameterReader| C("QueryExpression[]") -->|JADNC:ResourceService| D(QueryLayer) -->|JADNC:Repository| E(IQueryable) -->|Entity Framework Core| F[(SQL)] ``` Processing a request involves the following steps: diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 6cdea8b783..90dea1352b 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -81,42 +81,34 @@ In some cases it may be necessary to only expose a few actions on a resource. Fo This interface hierarchy is defined by this tree structure. -``` -IResourceService -| -+-- IResourceQueryService -| | -| +-- IGetAllService -| | GET / -| | -| +-- IGetByIdService -| | GET /{id} -| | -| +-- IGetSecondaryService -| | GET /{id}/{relationship} -| | -| +-- IGetRelationshipService -| GET /{id}/relationships/{relationship} -| -+-- IResourceCommandService - | - +-- ICreateService - | POST / - | - +-- IUpdateService - | PATCH /{id} - | - +-- IDeleteService - | DELETE /{id} - | - +-- IAddToRelationshipService - | POST /{id}/relationships/{relationship} - | - +-- ISetRelationshipService - | PATCH /{id}/relationships/{relationship} - | - +-- IRemoveFromRelationshipService - DELETE /{id}/relationships/{relationship} +```mermaid +classDiagram +direction LR +class IResourceService +class IResourceQueryService +class IGetAllService ["IGetAllService\nGET /"] +class IGetByIdService ["IGetByIdService\nGET /{id}"] +class IGetSecondaryService ["IGetSecondaryService\nGET /{id}/{relationship}"] +class IGetRelationshipService ["IGetRelationshipService\nGET /{id}/relationships/{relationship}"] +class IResourceCommandService +class ICreateService ["ICreateService\nPOST /"] +class IUpdateService ["IUpdateService\nPATCH /{id}"] +class IDeleteService ["IDeleteService\nDELETE /{id}"] +class IAddToRelationshipService ["IAddToRelationshipService\nPOST /{id}/relationships/{relationship}"] +class ISetRelationshipService ["ISetRelationshipService\nPATCH /{id}/relationships/{relationship}"] +class IRemoveFromRelationshipService ["IRemoveFromRelationshipService\nDELETE /{id}/relationships/{relationship}"] +IResourceService <|-- IResourceQueryService +IResourceQueryService<|-- IGetAllService +IResourceQueryService<|-- IGetByIdService +IResourceQueryService<|-- IGetSecondaryService +IResourceQueryService<|-- IGetRelationshipService +IResourceService <|-- IResourceCommandService +IResourceCommandService <|-- ICreateService +IResourceCommandService <|-- IUpdateService +IResourceCommandService <|-- IDeleteService +IResourceCommandService <|-- IAddToRelationshipService +IResourceCommandService <|-- ISetRelationshipService +IResourceCommandService <|-- IRemoveFromRelationshipService ``` In order to take advantage of these interfaces you first need to register the service for each implemented interface. diff --git a/docs/usage/writing/updating.md b/docs/usage/writing/updating.md index 01d0740cba..ea27e1a220 100644 --- a/docs/usage/writing/updating.md +++ b/docs/usage/writing/updating.md @@ -5,7 +5,7 @@ To modify the attributes of a single resource, send a PATCH request. The next example changes the article caption: ```http -POST /articles HTTP/1.1 +PATCH /articles HTTP/1.1 { "data": { diff --git a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs index 5e17afab9b..f9d5595123 100644 --- a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs +++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs @@ -3,11 +3,6 @@ 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/NoEntityFrameworkExample/Models/Person.cs b/src/Examples/NoEntityFrameworkExample/Models/Person.cs index 47a7f4da9a..2e7d4a02ab 100644 --- a/src/Examples/NoEntityFrameworkExample/Models/Person.cs +++ b/src/Examples/NoEntityFrameworkExample/Models/Person.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace NoEntityFrameworkExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class Person : Identifiable { [Attr] diff --git a/src/Examples/NoEntityFrameworkExample/Models/Tag.cs b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs index 425fe0923f..4a6ae70f49 100644 --- a/src/Examples/NoEntityFrameworkExample/Models/Tag.cs +++ b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace NoEntityFrameworkExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class Tag : Identifiable { [Attr] diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs index fb65a46015..f3eca749eb 100644 --- a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Linq.Expressions; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,13 +9,13 @@ namespace NoEntityFrameworkExample; internal sealed class QueryLayerToLinqConverter { - private readonly IResourceFactory _resourceFactory; private readonly IModel _model; + private readonly IQueryableBuilder _queryableBuilder; - public QueryLayerToLinqConverter(IResourceFactory resourceFactory, IModel model) + public QueryLayerToLinqConverter(IModel model, IQueryableBuilder queryableBuilder) { - _resourceFactory = resourceFactory; _model = model; + _queryableBuilder = queryableBuilder; } public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources) @@ -26,10 +26,9 @@ public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, converter.ConvertIncludesToSelections(); // Convert QueryLayer into LINQ expression. - Expression source = ((IEnumerable)resources).AsQueryable().Expression; - var nameFactory = new LambdaParameterNameFactory(); - var queryableBuilder = new QueryableBuilder(source, queryLayer.ResourceType.ClrType, typeof(Enumerable), nameFactory, _resourceFactory, _model); - Expression expression = queryableBuilder.ApplyQuery(queryLayer); + IQueryable source = ((IEnumerable)resources).AsQueryable(); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Enumerable), _model, null); + Expression expression = _queryableBuilder.ApplyQuery(queryLayer, context); // Insert null checks to prevent a NullReferenceException during execution of expressions such as: // 'todoItems => todoItems.Where(todoItem => todoItem.Assignee.Id == 1)' when a TodoItem doesn't have an assignee. diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs index 0b88ee3222..243b484a9b 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using NoEntityFrameworkExample.Data; @@ -25,12 +25,12 @@ public abstract class InMemoryResourceRepository : IResourceRead private readonly ResourceType _resourceType; private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; - protected InMemoryResourceRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + protected InMemoryResourceRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) { _resourceType = resourceGraph.GetResourceType(); var model = new InMemoryModel(resourceGraph); - _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(model, queryableBuilder); } /// diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs index d710cff0de..4a2fc5e72a 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Queries.QueryableBuilding; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -9,8 +9,8 @@ namespace NoEntityFrameworkExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class PersonRepository : InMemoryResourceRepository { - public PersonRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) + public PersonRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) + : base(resourceGraph, queryableBuilder) { } diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs index da38005bb3..30661d8bc1 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Queries.QueryableBuilding; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -9,8 +9,8 @@ namespace NoEntityFrameworkExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TagRepository : InMemoryResourceRepository { - public TagRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) + public TagRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) + : base(resourceGraph, queryableBuilder) { } diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs index 38cd656e0a..8156bf2798 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Queries.QueryableBuilding; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -9,8 +9,8 @@ namespace NoEntityFrameworkExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemRepository : InMemoryResourceRepository { - public TodoItemRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) + public TodoItemRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) + : base(resourceGraph, queryableBuilder) { } diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs index de9450298f..510d750fa7 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Services; @@ -42,7 +42,7 @@ public abstract class InMemoryResourceService : IResourceQuerySe private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, - IResourceFactory resourceFactory, IPaginationContext paginationContext, IEnumerable constraintProviders, + IPaginationContext paginationContext, IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, ILoggerFactory loggerFactory) { _options = options; @@ -54,7 +54,7 @@ protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resour _resourceType = resourceGraph.GetResourceType(); var model = new InMemoryModel(resourceGraph); - _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(model, queryableBuilder); } /// diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs index 11a4ad0b4a..5f8f96e0c6 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -10,9 +11,9 @@ namespace NoEntityFrameworkExample.Services; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemService : InMemoryResourceService { - public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IResourceFactory resourceFactory, - IPaginationContext paginationContext, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(options, resourceGraph, queryLayerComposer, resourceFactory, paginationContext, constraintProviders, loggerFactory) + public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, ILoggerFactory loggerFactory) + : base(options, resourceGraph, queryLayerComposer, paginationContext, constraintProviders, queryableBuilder, loggerFactory) { } diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index 1fe6858b96..0adaf34f74 100644 --- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -17,16 +17,19 @@ false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. logo.png + PackageReadme.md true true embedded + + true + + - - True - - + + diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs index bb854dac23..c129b4fdab 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations.Schema; -using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs similarity index 83% rename from src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs rename to src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs index b209964232..14c35b8e26 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs @@ -3,13 +3,31 @@ #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Resources.Internal; +namespace JsonApiDotNetCore.Resources; +/// +/// Provides utilities regarding runtime types. +/// [PublicAPI] public static class RuntimeTypeConverter { private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture"; + /// + /// Converts the specified value to the specified type. + /// + /// + /// The value to convert from. + /// + /// + /// The type to convert to. + /// + /// + /// The converted type, or null if is null and is a nullable type. + /// + /// + /// is not compatible with . + /// public static object? ConvertType(object? value, Type type) { ArgumentGuard.NotNull(type); @@ -114,11 +132,20 @@ public static class RuntimeTypeConverter } } + /// + /// Indicates whether the specified type is a nullable value type or a reference type. + /// public static bool CanContainNull(Type type) { return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } + /// + /// Gets the default value for the specified type. + /// + /// + /// The default value, or null for nullable value types and reference types. + /// public static object? GetDefaultValue(Type type) { return type.IsValueType ? Activator.CreateInstance(type) : null; diff --git a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj index 7246bf0781..c1ee323b72 100644 --- a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj +++ b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj @@ -15,16 +15,19 @@ false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. logo.png + PackageReadme.md true true embedded + + true + + - - True - - + + diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj b/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj index d2ad102392..7313901c64 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiDotNetCore.OpenApi.csproj @@ -15,16 +15,19 @@ false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. logo.png + PackageReadme.md true true embedded + + true + + - - True - - + + diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index 8bf3e90cf6..738ba46976 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -19,14 +19,17 @@ false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. logo.png + PackageReadme.md https://github.com/json-api-dotnet/JsonApiDotNetCore + + true + + - - True - - + + diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index 648906e901..0ce666e342 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -98,7 +98,7 @@ private void WriteNullableEnable() private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType, string? controllerNamespace) { - _sourceBuilder.AppendLine($@"using {loggerFactoryInterface.ContainingNamespace};"); + _sourceBuilder.AppendLine($"using {loggerFactoryInterface.ContainingNamespace};"); _sourceBuilder.AppendLine("using JsonApiDotNetCore.Configuration;"); _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;"); @@ -123,7 +123,7 @@ private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCo string baseClassName = GetControllerBaseClassName(endpointsToGenerate); WriteIndent(); - _sourceBuilder.AppendLine($@"public sealed partial class {controllerName} : {baseClassName}<{resourceType.Name}, {idType}>"); + _sourceBuilder.AppendLine($"public sealed partial class {controllerName} : {baseClassName}<{resourceType.Name}, {idType}>"); WriteOpenCurly(); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6ecdfd6077..73306e1237 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index 6a17fed7b9..f413898269 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -32,6 +32,18 @@ public static int FindIndex(this IReadOnlyList source, Predicate match) return -1; } + public static IEnumerable ToEnumerable(this LinkedListNode? startNode) + { + LinkedListNode? current = startNode; + + while (current != null) + { + yield return current.Value; + + current = current.Next; + } + } + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, IEqualityComparer? valueComparer = null) { diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 82e0ff52e1..c0b4638e40 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -2,9 +2,9 @@ using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.JsonConverters; @@ -193,6 +193,13 @@ private void AddRepositoryLayer() RegisterImplementationForInterfaces(ServiceDiscoveryFacade.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); _services.AddScoped(); + + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); } private void AddServiceLayer() @@ -210,6 +217,14 @@ private void RegisterImplementationForInterfaces(HashSet unboundInterfaces private void AddQueryStringLayer() { + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 9b73a09253..7ffea227e0 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -15,16 +15,19 @@ false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. logo.png + PackageReadme.md true true embedded + + true + + - - True - - + + diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs similarity index 97% rename from src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs rename to src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs index 6f3e0caf4e..cfbb0ab46b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index ecacc41c7c..dfa7536b03 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -1,17 +1,29 @@ using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') +/// This expression allows to test if an attribute value equals any of the specified constants. It represents the "any" filter function, resulting from +/// text such as: +/// +/// any(owner.name,'Jack','Joe','John') +/// +/// . /// [PublicAPI] public class AnyExpression : FilterExpression { + /// + /// The attribute whose value to compare. Chain format: an optional list of to-one relationships, followed by an attribute. + /// public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// One or more constants to compare the attribute's value against. + /// public IImmutableSet Constants { get; } public AnyExpression(ResourceFieldChainExpression targetAttribute, IImmutableSet constants) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index cdae713f3d..9259560776 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -4,13 +4,38 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') +/// This expression allows to compare two operands using a comparison operator. It represents comparison filter functions, resulting from text such as: +/// +/// equals(name,'Joe') +/// +/// , +/// +/// equals(owner,null) +/// +/// , or: +/// +/// greaterOrEqual(count(upVotes),count(downVotes),'1') +/// +/// . /// [PublicAPI] public class ComparisonExpression : FilterExpression { + /// + /// The operator used to compare and . + /// public ComparisonOperator Operator { get; } + + /// + /// The left-hand operand, which can be a function or a field chain. Chain format: an optional list of to-one relationships, followed by an attribute. + /// When comparing equality with null, the chain may also end in a to-one relationship. + /// public QueryExpression Left { get; } + + /// + /// The right-hand operand, which can be a function, a field chain, a constant, or null (if the type of is nullable). Chain format: + /// an optional list of to-one relationships, followed by an attribute. + /// public QueryExpression Right { get; } public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 2eff0a86e9..960cf6371b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -1,16 +1,29 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "count" function, resulting from text such as: count(articles) +/// This expression allows to determine the number of related resources in a to-many relationship. It represents the "count" function, resulting from +/// text such as: +/// +/// count(articles) +/// +/// . /// [PublicAPI] public class CountExpression : FunctionExpression { + /// + /// The to-many relationship to count related resources for. Chain format: an optional list of to-one relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression TargetCollection { get; } + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(int); + public CountExpression(ResourceFieldChainExpression targetCollection) { ArgumentGuard.NotNull(targetCollection); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs index 513fbf9ac8..447c8b6138 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs @@ -5,4 +5,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class FilterExpression : FunctionExpression { + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(bool); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs index 2e0b76b255..886a3906c8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs @@ -5,4 +5,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class FunctionExpression : QueryExpression { + /// + /// The CLR type this function returns. + /// + public abstract Type ReturnType { get; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index 825119fe33..709fd60916 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -1,16 +1,33 @@ using System.Text; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false')) +/// This expression allows to test if a to-many relationship has related resources, optionally with a condition. It represents the "has" filter function, +/// resulting from text such as: +/// +/// has(articles) +/// +/// , or: +/// +/// has(articles,equals(isHidden,'false')) +/// +/// . /// [PublicAPI] public class HasExpression : FilterExpression { + /// + /// The to-many relationship to determine related resources for. Chain format: an optional list of to-one relationships, followed by a to-many + /// relationship. + /// public ResourceFieldChainExpression TargetCollection { get; } + + /// + /// An optional filter that is applied on the related resources. Any related resources that do not match the filter are ignored. + /// public FilterExpression? Filter { get; } public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs index 133af83ad4..5b7d63ef96 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -1,7 +1,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the base type for an identifier, such as a field/relationship name, a constant between quotes or null. +/// Represents the base type for an identifier, such as a JSON:API attribute/relationship name, a constant value between quotes, or null. /// public abstract class IdentifierExpression : QueryExpression { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index 01c25dad4e..76991c6e78 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -6,12 +6,23 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in an tree, resulting from text such as: +/// +/// articles.revisions +/// +/// . /// [PublicAPI] public class IncludeElementExpression : QueryExpression { + /// + /// The JSON:API relationship to include. + /// public RelationshipAttribute Relationship { get; } + + /// + /// The direct children of this subtree. Can be empty. + /// public IImmutableSet Children { get; } public IncludeElementExpression(RelationshipAttribute relationship) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 69373c9abf..235e811fff 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -4,7 +4,11 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an inclusion tree, resulting from text such as: owner,articles.revisions +/// Represents an inclusion tree, resulting from text such as: +/// +/// owner,articles.revisions +/// +/// . /// [PublicAPI] public class IncludeExpression : QueryExpression @@ -13,6 +17,9 @@ public class IncludeExpression : QueryExpression public static readonly IncludeExpression Empty = new(); + /// + /// The direct children of this tree. Use if there are no children. + /// public IImmutableSet Elements { get; } public IncludeExpression(IImmutableSet elements) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs index 4e259b358e..c916aff3bc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs @@ -1,19 +1,42 @@ using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "isType" filter function, resulting from text such as: isType(,men), isType(creator,men) or +/// This expression allows to test if a resource in an inheritance hierarchy can be upcast to a derived type, optionally with a condition where the +/// derived type is accessible. It represents the "isType" filter function, resulting from text such as: +/// +/// isType(,men) +/// +/// , +/// +/// isType(creator,men) +/// +/// , or: +/// /// isType(creator,men,equals(hasBeard,'true')) +/// +/// . /// [PublicAPI] public class IsTypeExpression : FilterExpression { + /// + /// An optional to-one relationship to start from. Chain format: one or more to-one relationships. + /// public ResourceFieldChainExpression? TargetToOneRelationship { get; } + + /// + /// The derived resource type to upcast to. + /// public ResourceType DerivedType { get; } + + /// + /// An optional filter that the derived resource must match. + /// public FilterExpression? Child { get; } public IsTypeExpression(ResourceFieldChainExpression? targetToOneRelationship, ResourceType derivedType, FilterExpression? child) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index e5ca0c0318..1592aadcfd 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -4,14 +4,17 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') +/// Represents a non-null constant value, resulting from text such as: 'Jack', '123', or: 'true'. /// [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { - // Only used to show the original input, in case expression parse failed. Not part of the semantic expression value. + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. private readonly string _stringValue; + /// + /// The constant value. Call to determine the .NET runtime type. + /// public object TypedValue { get; } public LiteralConstantExpression(object typedValue) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index 08f970aee5..21a1b61e1c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -6,12 +6,28 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) +/// This expression allows to test whether one or all of its boolean operands are true. It represents the logical AND/OR filter functions, resulting from +/// text such as: +/// +/// and(equals(title,'Work'),has(articles)) +/// +/// , or: +/// +/// or(equals(title,'Work'),has(articles)) +/// +/// . /// [PublicAPI] public class LogicalExpression : FilterExpression { + /// + /// The operator used to compare . + /// public LogicalOperator Operator { get; } + + /// + /// The list of one or more boolean operands. + /// public IImmutableList Terms { get; } public LogicalExpression(LogicalOperator @operator, params FilterExpression[] terms) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 5d9ed08859..70a8fda395 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -5,13 +5,37 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') +/// This expression allows partial matching on the value of a JSON:API attribute. It represents text-matching filter functions, resulting from text such +/// as: +/// +/// startsWith(name,'The') +/// +/// , +/// +/// endsWith(name,'end.') +/// +/// , or: +/// +/// contains(name,'middle') +/// +/// . /// [PublicAPI] public class MatchTextExpression : FilterExpression { + /// + /// The attribute whose value to match. Chain format: an optional list of to-one relationships, followed by an attribute. + /// public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// The text to match the attribute's value against. + /// public LiteralConstantExpression TextValue { get; } + + /// + /// The kind of matching to perform. + /// public TextMatchKind MatchKind { get; } public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index ae198cd3ee..eaafb71afa 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -1,14 +1,21 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) +/// This expression allows to test for the logical negation of its operand. It represents the "not" filter function, resulting from text such as: +/// +/// not(equals(title,'Work')) +/// +/// . /// [PublicAPI] public class NotExpression : FilterExpression { + /// + /// The filter whose value to negate. + /// public FilterExpression Child { get; } public NotExpression(FilterExpression child) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index bdf1af317d..9685b6625c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -1,14 +1,17 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the constant null, resulting from text such as: equals(lastName,null) +/// Represents the constant null, resulting from the text: null. /// [PublicAPI] public class NullConstantExpression : IdentifierExpression { + /// + /// Provides access to the singleton instance. + /// public static readonly NullConstantExpression Instance = new(); private NullConstantExpression() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index 88846f3708..184fd7a3c1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -3,18 +3,35 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in , resulting from text such as: 1, or: +/// +/// articles:2 +/// +/// . /// [PublicAPI] public class PaginationElementQueryStringValueExpression : QueryExpression { + /// + /// The relationship this pagination applies to. Chain format: zero or more relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression? Scope { get; } + + /// + /// The numeric pagination value. + /// public int Value { get; } - public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value) + /// + /// The zero-based position in the text of the query string parameter value. + /// + public int Position { get; } + + public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value, int position) { Scope = scope; Value = value; + Position = position; } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -24,12 +41,12 @@ public override TResult Accept(QueryExpressionVisitor + /// The one-based page number. + /// public PageNumber PageNumber { get; } + + /// + /// The optional page size. + /// public PageSize? PageSize { get; } public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index a65e9c0a15..f16ca0cbd6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -4,11 +4,18 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents pagination in a query string, resulting from text such as: 1,articles:2 +/// Represents pagination in a query string, resulting from text such as: +/// +/// 1,articles:2 +/// +/// . /// [PublicAPI] public class PaginationQueryStringValueExpression : QueryExpression { + /// + /// The list of one or more pagination elements. + /// public IImmutableList Elements { get; } public PaginationQueryStringValueExpression(IImmutableList elements) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index 2ff93dafe4..dac7493109 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// /// Represents the base data structure for immutable types that query string parameters are converted into. This intermediate structure is later -/// transformed into system trees that are handled by Entity Framework Core. +/// transformed into System.Linq trees that are handled by Entity Framework Core. /// public abstract class QueryExpression { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 7051e81f73..a8e87f5db6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. +/// Building block for rewriting trees. It walks through nested expressions and updates the parent on changes. /// [PublicAPI] public class QueryExpressionRewriter : QueryExpressionVisitor @@ -105,25 +105,11 @@ public override QueryExpression VisitIsType(IsTypeExpression expression, TArgume public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { - SortElementExpression? newExpression = null; + QueryExpression? newTarget = Visit(expression.Target, argument); - if (expression.Count != null) - { - if (Visit(expression.Count, argument) is CountExpression newCount) - { - newExpression = new SortElementExpression(newCount, expression.IsAscending); - } - } - else if (expression.TargetAttribute != null) - { - if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) - { - newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); - } - } - - if (newExpression != null) + if (newTarget != null) { + var newExpression = new SortElementExpression(newTarget, expression.IsAscending); return newExpression.Equals(expression) ? expression : newExpression; } @@ -240,7 +226,7 @@ public override QueryExpression PaginationElementQueryStringValue(PaginationElem { ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value, expression.Position); return newExpression.Equals(expression) ? expression : newExpression; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index bc2d018033..9bc17fd3fa 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -3,12 +3,28 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... +/// Represents the relationship scope of a query string parameter, resulting from text such as: +/// +/// ?sort[articles] +/// +/// , or: +/// +/// ?filter[author.articles.comments] +/// +/// . /// [PublicAPI] public class QueryStringParameterScopeExpression : QueryExpression { + /// + /// The name of the query string parameter, without its surrounding brackets. + /// public LiteralConstantExpression ParameterName { get; } + + /// + /// The scope this parameter value applies to, or null for the URL endpoint scope. Chain format for the filter/sort parameters: an optional list + /// of relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression? Scope { get; } public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 9224642133..993e11f4f4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -1,15 +1,27 @@ using System.Collections.Immutable; using JetBrains.Annotations; +using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author +/// Represents a chain of JSON:API fields (relationships and attributes), resulting from text such as: +/// +/// articles.revisions.author +/// +/// , or: +/// +/// owner.LastName +/// +/// . /// [PublicAPI] public class ResourceFieldChainExpression : IdentifierExpression { + /// + /// A list of one or more JSON:API fields. Use to convert from text. + /// public IImmutableList Fields { get; } public ResourceFieldChainExpression(ResourceFieldAttribute field) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index bfdf30e8d5..154c44d159 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -4,28 +4,34 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in , resulting from text such as: lastName, +/// +/// -lastModifiedAt +/// +/// , or: +/// +/// count(children) +/// +/// . /// [PublicAPI] public class SortElementExpression : QueryExpression { - public ResourceFieldChainExpression? TargetAttribute { get; } - public CountExpression? Count { get; } + /// + /// The target to sort on, which can be a function or a field chain. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public QueryExpression Target { get; } + + /// + /// Indicates the sort direction. + /// public bool IsAscending { get; } - public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) + public SortElementExpression(QueryExpression target, bool isAscending) { - ArgumentGuard.NotNull(targetAttribute); + ArgumentGuard.NotNull(target); - TargetAttribute = targetAttribute; - IsAscending = isAscending; - } - - public SortElementExpression(CountExpression count, bool isAscending) - { - ArgumentGuard.NotNull(count); - - Count = count; + Target = target; IsAscending = isAscending; } @@ -53,14 +59,7 @@ private string InnerToString(bool toFullString) builder.Append('-'); } - if (TargetAttribute != null) - { - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); - } - else if (Count != null) - { - builder.Append(toFullString ? Count.ToFullString() : Count); - } + builder.Append(toFullString ? Target.ToFullString() : Target); return builder.ToString(); } @@ -79,11 +78,11 @@ public override bool Equals(object? obj) var other = (SortElementExpression)obj; - return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending; + return Equals(Target, other.Target) && IsAscending == other.IsAscending; } public override int GetHashCode() { - return HashCode.Combine(TargetAttribute, Count, IsAscending); + return HashCode.Combine(Target, IsAscending); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index 53b067d4e8..9c63e46013 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -4,11 +4,18 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt +/// Represents a sorting, resulting from text such as: +/// +/// lastName,-lastModifiedAt,count(children) +/// +/// . /// [PublicAPI] public class SortExpression : QueryExpression { + /// + /// One or more elements to sort on. + /// public IImmutableList Elements { get; } public SortExpression(IImmutableList elements) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index f36427b2e1..e075c3f915 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -5,11 +5,18 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles +/// Represents a sparse fieldset, resulting from text such as: +/// +/// firstName,lastName,articles +/// +/// . /// [PublicAPI] public class SparseFieldSetExpression : QueryExpression { + /// + /// The set of JSON:API fields to include. Chain format: a single field. + /// public IImmutableSet Fields { get; } public SparseFieldSetExpression(IImmutableSet fields) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index c69be71292..fc1e9fb88b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -11,6 +11,9 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class SparseFieldTableExpression : QueryExpression { + /// + /// The set of JSON:API fields to include, per resource type. + /// public IImmutableDictionary Table { get; } public SparseFieldTableExpression(IImmutableDictionary table) diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs similarity index 94% rename from src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs rename to src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs index 93b85c090e..bbc76a8269 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// /// Provides in-memory storage for the evaluated inclusion tree within a request. This tree is produced from query string and resource definition diff --git a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs similarity index 97% rename from src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs rename to src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs index 32a4724637..22046d3bca 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// /// Takes sparse fieldsets from s and invokes diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs deleted file mode 100644 index 4b779d1ccd..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Indicates how to handle derived types when resolving resource field chains. -/// -internal enum FieldChainInheritanceRequirement -{ - /// - /// Do not consider derived types when resolving attributes or relationships. - /// - Disabled, - - /// - /// Consider derived types when resolving attributes or relationships, but fail when multiple matches are found. - /// - RequireSingleMatch -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs deleted file mode 100644 index 58ab6f0830..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Used internally when parsing subexpressions in the query string parsers to indicate requirements when resolving a chain of fields. Note these may be -/// interpreted differently or even discarded completely by the various parser implementations, as they tend to better understand the characteristics of -/// the entire expression being parsed. -/// -[Flags] -public enum FieldChainRequirements -{ - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInAttribute = 1, - - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToOne = 2, - - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToMany = 4, - - /// - /// Indicates one or a chain of s. - /// - IsRelationship = EndsInToOne | EndsInToMany -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs deleted file mode 100644 index 541b50a220..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ /dev/null @@ -1,476 +0,0 @@ -using System.Collections.Immutable; -using Humanizer; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class FilterParser : QueryExpressionParser -{ - private readonly IResourceFactory _resourceFactory; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) - { - ArgumentGuard.NotNull(resourceFactory); - - _resourceFactory = resourceFactory; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public FilterExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope); - - return InScopeOfResourceType(resourceTypeInScope, () => - { - Tokenize(source); - - FilterExpression expression = ParseFilter(); - - AssertTokenStackIsEmpty(); - - return expression; - }); - } - - protected FilterExpression ParseFilter() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) - { - switch (nextToken.Value) - { - case Keywords.Not: - { - return ParseNot(); - } - case Keywords.And: - case Keywords.Or: - { - return ParseLogical(nextToken.Value); - } - case Keywords.Equals: - case Keywords.LessThan: - case Keywords.LessOrEqual: - case Keywords.GreaterThan: - case Keywords.GreaterOrEqual: - { - return ParseComparison(nextToken.Value); - } - case Keywords.Contains: - case Keywords.StartsWith: - case Keywords.EndsWith: - { - return ParseTextMatch(nextToken.Value); - } - case Keywords.Any: - { - return ParseAny(); - } - case Keywords.Has: - { - return ParseHas(); - } - case Keywords.IsType: - { - return ParseIsType(); - } - } - } - - throw new QueryParseException("Filter function expected."); - } - - protected NotExpression ParseNot() - { - EatText(Keywords.Not); - EatSingleCharacterToken(TokenKind.OpenParen); - - FilterExpression child = ParseFilter(); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new NotExpression(child); - } - - protected LogicalExpression ParseLogical(string operatorName) - { - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); - - ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); - - FilterExpression term = ParseFilter(); - termsBuilder.Add(term); - - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - termsBuilder.Add(term); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - termsBuilder.Add(term); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - var logicalOperator = Enum.Parse(operatorName.Pascalize()); - return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); - } - - protected ComparisonExpression ParseComparison(string operatorName) - { - var comparisonOperator = Enum.Parse(operatorName.Pascalize()); - - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); - - // Allow equality comparison of a HasOne relationship with null. - FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator.Equals - ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne - : FieldChainRequirements.EndsInAttribute; - - QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); - Converter rightConstantValueConverter; - - if (leftTerm is CountExpression) - { - rightConstantValueConverter = GetConstantValueConverterForCount(); - } - else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) - { - rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute); - } - else - { - // This temporary value never survives; it gets discarded during the second pass below. - rightConstantValueConverter = _ => 0; - } - - EatSingleCharacterToken(TokenKind.Comma); - - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); - - EatSingleCharacterToken(TokenKind.CloseParen); - - if (leftTerm is ResourceFieldChainExpression leftChain && leftChain.Fields[^1] is RelationshipAttribute && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to produce an error. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } - - return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); - } - - protected MatchTextExpression ParseTextMatch(string matchFunctionName) - { - EatText(matchFunctionName); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType; - - if (targetAttributeType != typeof(string)) - { - throw new QueryParseException("Attribute of type 'String' expected."); - } - - EatSingleCharacterToken(TokenKind.Comma); - - Converter constantValueConverter = stringValue => stringValue; - LiteralConstantExpression constant = ParseConstant(constantValueConverter); - - EatSingleCharacterToken(TokenKind.CloseParen); - - var matchKind = Enum.Parse(matchFunctionName.Pascalize()); - return new MatchTextExpression(targetAttributeChain, constant, matchKind); - } - - protected AnyExpression ParseAny() - { - EatText(Keywords.Any); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - Converter constantValueConverter = GetConstantValueConverterForAttribute((AttrAttribute)targetAttribute.Fields[^1]); - - EatSingleCharacterToken(TokenKind.Comma); - - ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - - LiteralConstantExpression constant = ParseConstant(constantValueConverter); - constantsBuilder.Add(constant); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(constantValueConverter); - constantsBuilder.Add(constant); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - IImmutableSet constantSet = constantsBuilder.ToImmutable(); - - return new AnyExpression(targetAttribute, constantSet); - } - - protected HasExpression ParseHas() - { - EatText(Keywords.Has); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - FilterExpression? filter = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields[^1]); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new HasExpression(targetCollection, filter); - } - - private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) - { - return InScopeOfResourceType(hasManyRelationship.RightType, ParseFilter); - } - - private IsTypeExpression ParseIsType() - { - EatText(Keywords.IsType); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); - - EatSingleCharacterToken(TokenKind.Comma); - - ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : _resourceTypeInScope!; - ResourceType derivedType = ParseDerivedType(baseType); - - FilterExpression? child = TryParseFilterInIsType(derivedType); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new IsTypeExpression(targetToOneRelationship, derivedType, child); - } - - private ResourceFieldChainExpression? TryParseToOneRelationshipChain() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - return null; - } - - return ParseFieldChain(FieldChainRequirements.EndsInToOne, "Relationship name or , expected."); - } - - private ResourceType ParseDerivedType(ResourceType baseType) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - string derivedTypeName = token.Value!; - return ResolveDerivedType(baseType, derivedTypeName); - } - - throw new QueryParseException("Resource type expected."); - } - - private ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName) - { - ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); - - if (derivedType == null) - { - throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'."); - } - - return derivedType; - } - - private ResourceType? GetDerivedType(ResourceType baseType, string publicName) - { - foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) - { - if (derivedType.PublicName == publicName) - { - return derivedType; - } - - ResourceType? nextType = GetDerivedType(derivedType, publicName); - - if (nextType != null) - { - return nextType; - } - } - - return null; - } - - private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) - { - FilterExpression? filter = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - filter = InScopeOfResourceType(derivedType, ParseFilter); - } - - return filter; - } - - protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) - { - CountExpression? count = TryParseCount(); - - if (count != null) - { - return count; - } - - return ParseFieldChain(chainRequirements, "Count function or field name expected."); - } - - protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter) - { - CountExpression? count = TryParseCount(); - - if (count != null) - { - return count; - } - - IdentifierExpression? constantOrNull = TryParseConstantOrNull(constantValueConverter); - - if (constantOrNull != null) - { - return constantOrNull; - } - - return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); - } - - protected IdentifierExpression? TryParseConstantOrNull(Converter constantValueConverter) - { - if (TokenStack.TryPeek(out Token? nextToken)) - { - if (nextToken is { Kind: TokenKind.Text, Value: Keywords.Null }) - { - TokenStack.Pop(); - return NullConstantExpression.Instance; - } - - if (nextToken.Kind == TokenKind.QuotedText) - { - TokenStack.Pop(); - - object constantValue = constantValueConverter(nextToken.Value!); - return new LiteralConstantExpression(constantValue, nextToken.Value!); - } - } - - return null; - } - - protected LiteralConstantExpression ParseConstant(Converter constantValueConverter) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) - { - object constantValue = constantValueConverter(token.Value!); - return new LiteralConstantExpression(constantValue, token.Value!); - } - - throw new QueryParseException("Value between quotes expected."); - } - - private Converter GetConstantValueConverterForCount() - { - return stringValue => ConvertStringToType(stringValue, typeof(int)); - } - - private object ConvertStringToType(string value, Type type) - { - try - { - return RuntimeTypeConverter.ConvertType(value, type)!; - } - catch (FormatException) - { - throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'."); - } - } - - private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute) - { - return stringValue => attribute.Property.Name == nameof(Identifiable.Id) - ? DeObfuscateStringId(attribute.Type.ClrType, stringValue) - : ConvertStringToType(stringValue, attribute.Property.PropertyType); - } - - private object DeObfuscateStringId(Type resourceClrType, string stringId) - { - IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); - tempResource.StringId = stringId; - return tempResource.GetTypedId(); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInToOne) - { - return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) - { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - - private TResult InScopeOfResourceType(ResourceType resourceType, Func action) - { - ResourceType? backupType = _resourceTypeInScope; - - try - { - _resourceTypeInScope = resourceType; - return action(); - } - finally - { - _resourceTypeInScope = backupType; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs deleted file mode 100644 index 27466e3b0a..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// The base class for parsing query string parameters, using the Recursive Descent algorithm. -/// -/// -/// Uses a tokenizer to populate a stack of tokens, which is then manipulated from the various parsing routines for subexpressions. Implementations -/// should throw on invalid input. -/// -[PublicAPI] -public abstract class QueryExpressionParser -{ - protected Stack TokenStack { get; private set; } = null!; - private protected ResourceFieldChainResolver ChainResolver { get; } = new(); - - /// - /// Takes a dotted path and walks the resource graph to produce a chain of fields. - /// - protected abstract IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); - - protected virtual void Tokenize(string source) - { - var tokenizer = new QueryTokenizer(source); - TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); - } - - protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) - { - var pathBuilder = new StringBuilder(); - EatFieldChain(pathBuilder, alternativeErrorMessage); - - IImmutableList chain = OnResolveFieldChain(pathBuilder.ToString(), chainRequirements); - - if (chain.Any()) - { - return new ResourceFieldChainExpression(chain); - } - - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); - } - - private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMessage) - { - while (true) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - pathBuilder.Append(token.Value); - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) - { - EatSingleCharacterToken(TokenKind.Period); - pathBuilder.Append('.'); - } - else - { - return; - } - } - else - { - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); - } - } - } - - protected CountExpression? TryParseCount() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count }) - { - TokenStack.Pop(); - - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new CountExpression(targetCollection); - } - - return null; - } - - protected void EatText(string text) - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) - { - throw new QueryParseException($"{text} expected."); - } - } - - protected void EatSingleCharacterToken(TokenKind kind) - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) - { - char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; - throw new QueryParseException($"{ch} expected."); - } - } - - protected void AssertTokenStackIsEmpty() - { - if (TokenStack.Any()) - { - throw new QueryParseException("End of expression expected."); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs deleted file mode 100644 index 2265ca56da..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public sealed class QueryParseException : Exception -{ - public QueryParseException(string message) - : base(message) - { - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs deleted file mode 100644 index ef95b3ed92..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class QueryStringParameterScopeParser : QueryExpressionParser -{ - private readonly FieldChainRequirements _chainRequirements; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, - Action? validateSingleFieldCallback = null) - { - _chainRequirements = chainRequirements; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) - { - throw new QueryParseException("Parameter name expected."); - } - - var name = new LiteralConstantExpression(token.Value!); - - ResourceFieldChainExpression? scope = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) - { - TokenStack.Pop(); - - scope = ParseFieldChain(_chainRequirements, null); - - EatSingleCharacterToken(TokenKind.CloseBracket); - } - - return new QueryStringParameterScopeExpression(name, scope); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.IsRelationship) - { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs deleted file mode 100644 index 6630cf2767..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -internal enum ResourceFieldCategory -{ - Field, - Attribute, - Relationship -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs deleted file mode 100644 index e15b14893a..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -internal sealed class ResourceFieldChainErrorFormatter -{ - public string GetForNotFound(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, - FieldChainInheritanceRequirement inheritanceRequirement) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append($" does not exist on resource type '{resourceType.PublicName}'"); - - if (inheritanceRequirement != FieldChainInheritanceRequirement.Disabled && resourceType.DirectlyDerivedTypes.Any()) - { - builder.Append(" or any of its derived types"); - } - - builder.Append('.'); - - return builder.ToString(); - } - - public string GetForMultipleMatches(ResourceFieldCategory category, string publicName, string path) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append(" is defined on multiple derived types."); - - return builder.ToString(); - } - - public string GetForWrongFieldType(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, string expected) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append($" must be {expected} on resource type '{resourceType.PublicName}'."); - - return builder.ToString(); - } - - public string GetForNoneFound(ResourceFieldCategory category, string publicName, string path, ICollection parentResourceTypes, - bool hasDerivedTypes) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - if (parentResourceTypes.Count == 1) - { - builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); - } - else - { - string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); - builder.Append($" does not exist on any of the resource types {typeNames}"); - } - - builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); - - return builder.ToString(); - } - - private static void WriteSource(ResourceFieldCategory category, string publicName, StringBuilder builder) - { - builder.Append($"{category} '{publicName}'"); - } - - private static void WritePath(string path, string publicName, StringBuilder builder) - { - if (path != publicName) - { - builder.Append($" in '{path}'"); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs deleted file mode 100644 index 4fb2632557..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ /dev/null @@ -1,301 +0,0 @@ -using System.Collections.Immutable; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Provides helper methods to resolve a chain of fields (relationships and attributes) from the resource graph. -/// -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 - /// - public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(relationship, nextResourceType, path); - - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } - - string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); - - chainBuilder.Add(lastToManyRelationship); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of relationships. - /// - /// blogs.articles.comments - /// - /// - /// author.address - /// - /// - /// articles.revisions.author - /// - /// - public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(relationship, nextResourceType, path); - - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } - - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in an attribute. - /// - /// author.address.country.name - /// - /// name - /// - public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(lastAttribute, nextResourceType, path); - - chainBuilder.Add(lastAttribute); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in a to-many relationship. - /// - /// article.comments - /// - /// - /// comments - /// - /// - public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toManyRelationship, nextResourceType, path); - - chainBuilder.Add(toManyRelationship); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in either an attribute or a to-one relationship. - /// - /// author.address.country.name - /// - /// - /// author.address - /// - /// - public IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - ResourceFieldAttribute lastField = GetField(lastName, nextResourceType, path); - - if (lastField is HasManyAttribute) - { - 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); - - chainBuilder.Add(lastField); - return chainBuilder.ToImmutable(); - } - - private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - IReadOnlyCollection relationships = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled - ? resourceType.FindRelationshipByPublicName(publicName)?.AsArray() ?? Array.Empty() - : resourceType.GetRelationshipsInTypeOrDerived(publicName); - - if (relationships.Count == 0) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Relationship, publicName, path, resourceType, inheritanceRequirement); - throw new QueryParseException(message); - } - - 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, - FieldChainInheritanceRequirement inheritanceRequirement) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); - - if (relationship is not HasManyAttribute) - { - 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, - FieldChainInheritanceRequirement inheritanceRequirement) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); - - if (relationship is not HasOneAttribute) - { - 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, FieldChainInheritanceRequirement inheritanceRequirement) - { - IReadOnlyCollection attributes = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled - ? resourceType.FindAttributeByPublicName(publicName)?.AsArray() ?? Array.Empty() - : resourceType.GetAttributesInTypeOrDerived(publicName); - - if (attributes.Count == 0) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Attribute, publicName, path, resourceType, inheritanceRequirement); - throw new QueryParseException(message); - } - - 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) - { - ResourceFieldAttribute? field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); - - if (field == null) - { - 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 deleted file mode 100644 index 7f4a142ef0..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class SortParser : QueryExpressionParser -{ - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public SortParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public SortExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - SortExpression expression = ParseSort(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected SortExpression ParseSort() - { - SortElementExpression firstElement = ParseSortElement(); - - ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); - elementsBuilder.Add(firstElement); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - SortElementExpression nextElement = ParseSortElement(); - elementsBuilder.Add(nextElement); - } - - return new SortExpression(elementsBuilder.ToImmutable()); - } - - protected SortElementExpression ParseSortElement() - { - bool isAscending = true; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) - { - TokenStack.Pop(); - isAscending = false; - } - - CountExpression? count = TryParseCount(); - - if (count != null) - { - return new SortElementExpression(count, isAscending); - } - - string errorMessage = isAscending ? "-, count function or field name expected." : "Count function or field name expected."; - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, errorMessage); - return new SortElementExpression(targetAttribute, isAscending); - } - - 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, FieldChainInheritanceRequirement.RequireSingleMatch); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - 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/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs deleted file mode 100644 index bff295acae..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public sealed class Token -{ - public TokenKind Kind { get; } - public string? Value { get; } - - public Token(TokenKind kind) - { - Kind = kind; - } - - public Token(TokenKind kind, string value) - : this(kind) - { - Value = value; - } - - public override string ToString() - { - return Value == null ? Kind.ToString() : $"{Kind}: {Value}"; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs deleted file mode 100644 index 32691e05ab..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Humanizer; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Produces unique names for lambda parameters. -/// -[PublicAPI] -public sealed class LambdaParameterNameFactory -{ - private readonly HashSet _namesInScope = new(); - - public LambdaParameterNameScope Create(string typeName) - { - ArgumentGuard.NotNullNorEmpty(typeName); - - string parameterName = typeName.Camelize(); - parameterName = EnsureNameIsUnique(parameterName); - - _namesInScope.Add(parameterName); - return new LambdaParameterNameScope(parameterName, this); - } - - private string EnsureNameIsUnique(string name) - { - if (!_namesInScope.Contains(name)) - { - return name; - } - - int counter = 1; - string alternativeName; - - do - { - counter++; - alternativeName = name + counter; - } - while (_namesInScope.Contains(alternativeName)); - - return alternativeName; - } - - public void Release(string parameterName) - { - _namesInScope.Remove(parameterName); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs deleted file mode 100644 index 031dae0a0f..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs +++ /dev/null @@ -1,25 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -[PublicAPI] -public sealed class LambdaParameterNameScope : IDisposable -{ - private readonly LambdaParameterNameFactory _owner; - - public string Name { get; } - - public LambdaParameterNameScope(string name, LambdaParameterNameFactory owner) - { - ArgumentGuard.NotNullNorEmpty(name); - ArgumentGuard.NotNull(owner); - - Name = name; - _owner = owner; - } - - public void Dispose() - { - _owner.Release(Name); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs deleted file mode 100644 index 52caddbe62..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Contains details on a lambda expression, such as the name of the selector "x" in "x => x.Name". -/// -[PublicAPI] -public sealed class LambdaScope : IDisposable -{ - private readonly LambdaParameterNameScope _parameterNameScope; - - public ParameterExpression Parameter { get; } - public Expression Accessor { get; } - - private LambdaScope(LambdaParameterNameScope parameterNameScope, ParameterExpression parameter, Expression accessor) - { - _parameterNameScope = parameterNameScope; - Parameter = parameter; - Accessor = accessor; - } - - public static LambdaScope Create(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) - { - ArgumentGuard.NotNull(nameFactory); - ArgumentGuard.NotNull(elementType); - - LambdaParameterNameScope parameterNameScope = nameFactory.Create(elementType.Name); - ParameterExpression parameter = Expression.Parameter(elementType, parameterNameScope.Name); - Expression accessor = accessorExpression ?? parameter; - - return new LambdaScope(parameterNameScope, parameter, accessor); - } - - public LambdaScope WithAccessor(Expression accessorExpression) - { - ArgumentGuard.NotNull(accessorExpression); - - return new LambdaScope(_parameterNameScope, Parameter, accessorExpression); - } - - public void Dispose() - { - _parameterNameScope.Dispose(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs deleted file mode 100644 index 6e4955cf40..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -[PublicAPI] -public sealed class LambdaScopeFactory -{ - private readonly LambdaParameterNameFactory _nameFactory; - - public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) - { - ArgumentGuard.NotNull(nameFactory); - - _nameFactory = nameFactory; - } - - public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) - { - ArgumentGuard.NotNull(elementType); - - return LambdaScope.Create(_nameFactory, elementType, accessorExpression); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs deleted file mode 100644 index 775893adcc..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into -/// calls. -/// -[PublicAPI] -public class OrderClauseBuilder : QueryClauseBuilder -{ - private readonly Expression _source; - private readonly Type _extensionType; - - public OrderClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(extensionType); - - _source = source; - _extensionType = extensionType; - } - - public Expression ApplyOrderBy(SortExpression expression) - { - ArgumentGuard.NotNull(expression); - - return Visit(expression, null); - } - - public override Expression VisitSort(SortExpression expression, Expression? argument) - { - Expression? sortExpression = null; - - foreach (SortElementExpression sortElement in expression.Elements) - { - sortExpression = Visit(sortElement, sortExpression); - } - - return sortExpression!; - } - - public override Expression VisitSortElement(SortElementExpression expression, Expression? previousExpression) - { - Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute!, null); - - LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); - - string operationName = GetOperationName(previousExpression != null, expression.IsAscending); - - return ExtensionMethodCall(previousExpression ?? _source, operationName, body.Type, lambda); - } - - private static string GetOperationName(bool hasPrecedingSort, bool isAscending) - { - if (hasPrecedingSort) - { - return isAscending ? "ThenBy" : "ThenByDescending"; - } - - return isAscending ? "OrderBy" : "OrderByDescending"; - } - - private Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector) - { - Type[] typeArguments = ArrayFactory.Create(LambdaScope.Parameter.Type, keyType); - return Expression.Call(_extensionType, operationName, typeArguments, source, keySelector); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs deleted file mode 100644 index a497846285..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Drives conversion from into system trees. -/// -[PublicAPI] -public class QueryableBuilder -{ - private readonly Expression _source; - private readonly Type _elementType; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - private readonly IResourceFactory _resourceFactory; - private readonly IModel _entityModel; - private readonly LambdaScopeFactory _lambdaScopeFactory; - - public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, IResourceFactory resourceFactory, - IModel entityModel, LambdaScopeFactory? lambdaScopeFactory = null) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(elementType); - ArgumentGuard.NotNull(extensionType); - ArgumentGuard.NotNull(nameFactory); - ArgumentGuard.NotNull(resourceFactory); - ArgumentGuard.NotNull(entityModel); - - _source = source; - _elementType = elementType; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - _entityModel = entityModel; - _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); - } - - public virtual Expression ApplyQuery(QueryLayer layer) - { - ArgumentGuard.NotNull(layer); - - Expression expression = _source; - - if (layer.Include != null) - { - expression = ApplyInclude(expression, layer.Include, layer.ResourceType); - } - - if (layer.Filter != null) - { - expression = ApplyFilter(expression, layer.Filter); - } - - if (layer.Sort != null) - { - expression = ApplySort(expression, layer.Sort); - } - - if (layer.Pagination != null) - { - expression = ApplyPagination(expression, layer.Pagination); - } - - if (layer.Selection is { IsEmpty: false }) - { - expression = ApplySelection(expression, layer.Selection, layer.ResourceType); - } - - return expression; - } - - protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new IncludeClauseBuilder(source, lambdaScope, resourceType); - return builder.ApplyInclude(include); - } - - protected virtual Expression ApplyFilter(Expression source, FilterExpression filter) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory); - return builder.ApplyWhere(filter); - } - - protected virtual Expression ApplySort(Expression source, SortExpression sort) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplyOrderBy(sort); - } - - protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplySkipTake(pagination); - } - - 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(selection, resourceType); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs deleted file mode 100644 index 90109dbfec..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into and -/// calls. -/// -[PublicAPI] -public class SkipTakeClauseBuilder : QueryClauseBuilder -{ - private readonly Expression _source; - private readonly Type _extensionType; - - public SkipTakeClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(extensionType); - - _source = source; - _extensionType = extensionType; - } - - public Expression ApplySkipTake(PaginationExpression expression) - { - ArgumentGuard.NotNull(expression); - - return Visit(expression, null); - } - - public override Expression VisitPagination(PaginationExpression expression, object? argument) - { - Expression skipTakeExpression = _source; - - if (expression.PageSize != null) - { - int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; - - if (skipValue > 0) - { - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue); - } - - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value); - } - - return skipTakeExpression; - } - - private Expression ExtensionMethodCall(Expression source, string operationName, int value) - { - Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); - - return Expression.Call(_extensionType, operationName, LambdaScope.Parameter.Type.AsArray(), source, constant); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs new file mode 100644 index 0000000000..1af9656154 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -0,0 +1,602 @@ +using System.Collections.Immutable; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class FilterParser : QueryExpressionParser, IFilterParser +{ + private static readonly HashSet FilterKeywords = new(new[] + { + Keywords.Not, + Keywords.And, + Keywords.Or, + Keywords.Equals, + Keywords.GreaterThan, + Keywords.GreaterOrEqual, + Keywords.LessThan, + Keywords.LessOrEqual, + Keywords.Contains, + Keywords.StartsWith, + Keywords.EndsWith, + Keywords.Any, + Keywords.Count, + Keywords.Has, + Keywords.IsType + }); + + private readonly IResourceFactory _resourceFactory; + private readonly Stack _resourceTypeStack = new(); + + /// + /// Gets the resource type currently in scope. Call to temporarily change the current resource type. + /// + protected ResourceType ResourceTypeInScope + { + get + { + if (_resourceTypeStack.Count == 0) + { + throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + } + + return _resourceTypeStack.Peek(); + } + } + + public FilterParser(IResourceFactory resourceFactory) + { + ArgumentGuard.NotNull(resourceFactory); + + _resourceFactory = resourceFactory; + } + + /// + public FilterExpression Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + Tokenize(source); + + _resourceTypeStack.Clear(); + FilterExpression expression; + + using (InScopeOfResourceType(resourceType)) + { + expression = ParseFilter(); + + AssertTokenStackIsEmpty(); + } + + AssertResourceTypeStackIsEmpty(); + + return expression; + } + + protected virtual bool IsFunction(string name) + { + ArgumentGuard.NotNullNorEmpty(name); + + return name == Keywords.Count || FilterKeywords.Contains(name); + } + + protected virtual FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Count: + { + return ParseCount(); + } + } + } + + return ParseFilter(); + } + + private CountExpression ParseCount() + { + EatText(Keywords.Count); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + protected virtual FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Not: + { + return ParseNot(); + } + case Keywords.And: + case Keywords.Or: + { + return ParseLogical(nextToken.Value); + } + case Keywords.Equals: + case Keywords.LessThan: + case Keywords.LessOrEqual: + case Keywords.GreaterThan: + case Keywords.GreaterOrEqual: + { + return ParseComparison(nextToken.Value); + } + case Keywords.Contains: + case Keywords.StartsWith: + case Keywords.EndsWith: + { + return ParseTextMatch(nextToken.Value); + } + case Keywords.Any: + { + return ParseAny(); + } + case Keywords.Has: + { + return ParseHas(); + } + case Keywords.IsType: + { + return ParseIsType(); + } + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Filter function expected.", position); + } + + protected virtual NotExpression ParseNot() + { + EatText(Keywords.Not); + EatSingleCharacterToken(TokenKind.OpenParen); + + FilterExpression child = ParseFilter(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new NotExpression(child); + } + + protected virtual LogicalExpression ParseLogical(string operatorName) + { + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); + + FilterExpression term = ParseFilter(); + termsBuilder.Add(term); + + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + var logicalOperator = Enum.Parse(operatorName.Pascalize()); + return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); + } + + protected virtual ComparisonExpression ParseComparison(string operatorName) + { + var comparisonOperator = Enum.Parse(operatorName.Pascalize()); + + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + QueryExpression leftTerm = ParseComparisonLeftTerm(comparisonOperator); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); + } + + private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOperator) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + // Allow equality comparison of a to-one relationship with null. + FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals + ? BuiltInPatterns.ToOneChainEndingInAttributeOrToOne + : BuiltInPatterns.ToOneChainEndingInAttribute; + + return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected."); + } + + private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) + { + if (leftTerm is ResourceFieldChainExpression leftFieldChain) + { + ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; + + if (leftLastField is HasOneAttribute) + { + return ParseNull(); + } + + var leftAttribute = (AttrAttribute)leftLastField; + + Func constantValueConverter = GetConstantValueConverterForAttribute(leftAttribute); + return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter); + } + + if (leftTerm is FunctionExpression leftFunction) + { + Func constantValueConverter = GetConstantValueConverterForType(leftFunction.ReturnType); + return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter); + } + + throw new InvalidOperationException( + $"Internal error: Expected left term to be a function or field chain, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); + } + + private QueryExpression ParseTypedComparisonRightTerm(Type leftType, Func constantValueConverter) + { + bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); + + string errorMessage = + allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."; + + if (TokenStack.TryPeek(out Token? nextToken)) + { + if (nextToken is { Kind: TokenKind.QuotedText }) + { + TokenStack.Pop(); + + object constantValue = constantValueConverter(nextToken.Value!, nextToken.Position); + return new LiteralConstantExpression(constantValue, nextToken.Value!); + } + + if (nextToken.Kind == TokenKind.Text) + { + if (nextToken.Value == Keywords.Null) + { + if (!allowNull) + { + throw new QueryParseException(errorMessage, nextToken.Position); + } + + TokenStack.Pop(); + return NullConstantExpression.Instance; + } + + if (IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage); + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(errorMessage, position); + } + + protected virtual MatchTextExpression ParseTextMatch(string operatorName) + { + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + if (targetAttribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.Comma); + + Func constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + + EatSingleCharacterToken(TokenKind.CloseParen); + + var matchKind = Enum.Parse(operatorName.Pascalize()); + return new MatchTextExpression(targetAttributeChain, constant, matchKind); + } + + protected virtual AnyExpression ParseAny() + { + EatText(Keywords.Any); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + EatSingleCharacterToken(TokenKind.Comma); + + ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); + + Func constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + constantsBuilder.Add(constant); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + constant = ParseConstant(constantValueConverter); + constantsBuilder.Add(constant); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + IImmutableSet constantSet = constantsBuilder.ToImmutable(); + + return new AnyExpression(targetAttributeChain, constantSet); + } + + protected virtual HasExpression ParseHas() + { + EatText(Keywords.Has); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + var hasManyRelationship = (HasManyAttribute)targetCollection.Fields[^1]; + + using (InScopeOfResourceType(hasManyRelationship.RightType)) + { + filter = ParseFilter(); + } + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new HasExpression(targetCollection, filter); + } + + protected virtual IsTypeExpression ParseIsType() + { + EatText(Keywords.IsType); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + + EatSingleCharacterToken(TokenKind.Comma); + + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : ResourceTypeInScope; + ResourceType derivedType = ParseDerivedType(baseType); + + FilterExpression? child = TryParseFilterInIsType(derivedType); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsTypeExpression(targetToOneRelationship, derivedType, child); + } + + private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + return null; + } + + return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected."); + } + + private ResourceType ParseDerivedType(ResourceType baseType) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + string derivedTypeName = token.Value!; + return ResolveDerivedType(baseType, derivedTypeName, token.Position); + } + + throw new QueryParseException("Resource type expected.", position); + } + + private static ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName, int position) + { + ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); + + if (derivedType == null) + { + throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'.", position); + } + + return derivedType; + } + + private static ResourceType? GetDerivedType(ResourceType baseType, string publicName) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + if (derivedType.PublicName == publicName) + { + return derivedType; + } + + ResourceType? nextType = GetDerivedType(derivedType, publicName); + + if (nextType != null) + { + return nextType; + } + } + + return null; + } + + private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) + { + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + using (InScopeOfResourceType(derivedType)) + { + filter = ParseFilter(); + } + } + + return filter; + } + + private LiteralConstantExpression ParseConstant(Func constantValueConverter) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + { + object constantValue = constantValueConverter(token.Value!, token.Position); + return new LiteralConstantExpression(constantValue, token.Value!); + } + + throw new QueryParseException("Value between quotes expected.", position); + } + + private NullConstantExpression ParseNull() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token is { Kind: TokenKind.Text, Value: Keywords.Null }) + { + return NullConstantExpression.Instance; + } + + throw new QueryParseException("null expected.", position); + } + + private static Func GetConstantValueConverterForType(Type destinationType) + { + return (stringValue, position) => + { + try + { + return RuntimeTypeConverter.ConvertType(stringValue, destinationType)!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{stringValue}' of type 'String' to type '{destinationType.Name}'.", position, exception); + } + }; + } + + private Func GetConstantValueConverterForAttribute(AttrAttribute attribute) + { + if (attribute is { Property.Name: nameof(Identifiable.Id) }) + { + return (stringValue, position) => + { + try + { + return DeObfuscateStringId(attribute.Type, stringValue); + } + catch (JsonApiException exception) + { + throw new QueryParseException(exception.Errors[0].Detail!, position); + } + }; + } + + return GetConstantValueConverterForType(attribute.Property.PropertyType); + } + + private object DeObfuscateStringId(ResourceType resourceType, string stringId) + { + IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceType.ClrType); + tempResource.StringId = stringId; + return tempResource.GetTypedId(); + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + if (field.IsFilterBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Filtering on {kind} '{field.PublicName}' is not allowed.", position); + } + } + + /// + /// Changes the resource type currently in scope and restores the original resource type when the return value is disposed. + /// + protected IDisposable InScopeOfResourceType(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + _resourceTypeStack.Push(resourceType); + return new PopResourceTypeOnDispose(_resourceTypeStack); + } + + private void AssertResourceTypeStackIsEmpty() + { + if (_resourceTypeStack.Count > 0) + { + throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " + + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}()."); + } + } + + private sealed class PopResourceTypeOnDispose : IDisposable + { + private readonly Stack _resourceTypeStack; + + public PopResourceTypeOnDispose(Stack resourceTypeStack) + { + _resourceTypeStack = resourceTypeStack; + } + + public void Dispose() + { + _resourceTypeStack.Pop(); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs new file mode 100644 index 0000000000..289a745027 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'filter' query string parameter value. +/// +public interface IFilterParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + FilterExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs new file mode 100644 index 0000000000..2524d1ef4c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'include' query string parameter value. +/// +public interface IIncludeParser +{ + /// + /// Parses the specified source into an . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + IncludeExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs new file mode 100644 index 0000000000..bd15ac2b3c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'page' query string parameter value. +/// +public interface IPaginationParser +{ + /// + /// Parses the specified source into a . Throws a if the input is + /// invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + /// + /// Due to the syntax of the JSON:API pagination parameter, The returned is an intermediate value + /// that gets converted into by . + /// + PaginationQueryStringValueExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..22cd2b1426 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'sort' and 'filter' query string parameter names, which contain a resource field chain that indicates the scope its query string +/// parameter value applies to. +/// +public interface IQueryStringParameterScopeParser +{ + /// + /// Parses the specified source into a . Throws a if the input is + /// invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + /// + /// The pattern that the field chain in must match. + /// + /// + /// The match options for . + /// + QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs new file mode 100644 index 0000000000..be5f72545f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'sort' query string parameter value. +/// +public interface ISortParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + SortExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs new file mode 100644 index 0000000000..acec82b8f2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'fields' query string parameter value. +/// +public interface ISparseFieldSetParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + SparseFieldSetExpression? Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs new file mode 100644 index 0000000000..fd5cc0aec5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'fields' query string parameter name. +/// +public interface ISparseFieldTypeParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + ResourceType Parse(string source); +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs similarity index 70% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs rename to src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs index 1250e36312..27fffb9467 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -6,30 +6,39 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; +/// [PublicAPI] -public class IncludeParser : QueryExpressionParser +public class IncludeParser : QueryExpressionParser, IIncludeParser { - private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); + private readonly IJsonApiOptions _options; - public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) + public IncludeParser(IJsonApiOptions options) { - ArgumentGuard.NotNull(resourceTypeInScope); + ArgumentGuard.NotNull(options); + + _options = options; + } + + /// + public IncludeExpression Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); Tokenize(source); - IncludeExpression expression = ParseInclude(resourceTypeInScope, maximumDepth); + IncludeExpression expression = ParseInclude(source, resourceType); AssertTokenStackIsEmpty(); - ValidateMaximumIncludeDepth(maximumDepth, expression); + ValidateMaximumIncludeDepth(expression, 0); return expression; } - protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? maximumDepth) + protected virtual IncludeExpression ParseInclude(string source, ResourceType resourceType) { - var treeRoot = IncludeTreeNode.CreateRoot(resourceTypeInScope); + var treeRoot = IncludeTreeNode.CreateRoot(resourceType); bool isAtStart = true; while (TokenStack.Any()) @@ -43,13 +52,13 @@ protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? isAtStart = false; } - ParseRelationshipChain(treeRoot); + ParseRelationshipChain(source, treeRoot); } return treeRoot.ToExpression(); } - private void ParseRelationshipChain(IncludeTreeNode treeRoot) + private void ParseRelationshipChain(string source, IncludeTreeNode treeRoot) { // A relationship name usually matches a single relationship, even when overridden in derived types. // But in the following case, two relationships are matched on GET /shoppingBaskets?include=items: @@ -77,27 +86,30 @@ private void ParseRelationshipChain(IncludeTreeNode treeRoot) // that there's currently no way to include Products without Articles. We could add such optional upcast syntax // in the future, if desired. - ICollection children = ParseRelationshipName(treeRoot.AsList()); + ICollection children = ParseRelationshipName(source, treeRoot.AsList()); while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) { EatSingleCharacterToken(TokenKind.Period); - children = ParseRelationshipName(children); + children = ParseRelationshipName(source, children); } } - private ICollection ParseRelationshipName(ICollection parents) + private ICollection ParseRelationshipName(string source, ICollection parents) { + int position = GetNextTokenPositionOrEnd(); + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - return LookupRelationshipName(token.Value!, parents); + return LookupRelationshipName(token.Value!, parents, source, position); } - throw new QueryParseException("Relationship name expected."); + throw new QueryParseException("Relationship name expected.", position); } - private ICollection LookupRelationshipName(string relationshipName, ICollection parents) + private static ICollection LookupRelationshipName(string relationshipName, ICollection parents, string source, + int position) { List children = new(); HashSet relationshipsFound = new(); @@ -118,116 +130,109 @@ private ICollection LookupRelationshipName(string relationshipN } } - AssertRelationshipsFound(relationshipsFound, relationshipName, parents); - AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, parents); + AssertRelationshipsFound(relationshipsFound, relationshipName, parents, position); + AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, source, position); return children; } - private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents) + private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents, + int position) { if (relationshipsFound.Any()) { return; } - string[] parentPaths = parents.Select(parent => parent.Path).Distinct().Where(path => path != string.Empty).ToArray(); - string path = parentPaths.Length > 0 ? $"{parentPaths[0]}.{relationshipName}" : relationshipName; - ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray(); bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0); - string message = ErrorFormatter.GetForNoneFound(ResourceFieldCategory.Relationship, relationshipName, path, parentResourceTypes, hasDerivedTypes); - throw new QueryParseException(message); + string message = GetErrorMessageForNoneFound(relationshipName, parentResourceTypes, hasDerivedTypes); + throw new QueryParseException(message, position); + } + + private static string GetErrorMessageForNoneFound(string relationshipName, ICollection parentResourceTypes, bool hasDerivedTypes) + { + var builder = new StringBuilder($"Relationship '{relationshipName}'"); + + if (parentResourceTypes.Count == 1) + { + builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); + } + else + { + string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); + builder.Append($" does not exist on any of the resource types {typeNames}"); + } + + builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); + + return builder.ToString(); } - private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, - ICollection parents) + private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, string source, int position) { if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked())) { - string parentPath = parents.First().Path; ResourceType resourceType = relationshipsFound.First().LeftType; + string message = $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed."; - string message = parentPath == string.Empty - ? $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed." - : $"Including the relationship '{relationshipName}' in '{parentPath}.{relationshipName}' on '{resourceType}' is not allowed."; + var exception = new QueryParseException(message, position); + string specificMessage = exception.GetMessageWithPosition(source); - throw new InvalidQueryStringParameterException("include", "Including the requested relationship is not allowed.", message); + throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", specificMessage); } } - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IncludeExpression include) + private void ValidateMaximumIncludeDepth(IncludeExpression include, int position) { - if (maximumDepth != null) + if (_options.MaximumIncludeDepth != null) { + int maximumDepth = _options.MaximumIncludeDepth.Value; Stack parentChain = new(); foreach (IncludeElementExpression element in include.Elements) { - ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth.Value); + ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth, position); } } } - private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth) + private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth, + int position) { parentChain.Push(includeElement.Relationship); if (parentChain.Count > maximumDepth) { string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}.", position); } foreach (IncludeElementExpression child in includeElement.Children) { - ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth); + ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth, position); } parentChain.Pop(); } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - throw new NotSupportedException(); - } - private sealed class IncludeTreeNode { - private readonly IncludeTreeNode? _parent; private readonly IDictionary _children = new Dictionary(); public RelationshipAttribute Relationship { get; } - public string Path - { - get - { - var pathBuilder = new StringBuilder(); - IncludeTreeNode? parent = this; - - while (parent is { Relationship: not HiddenRootRelationshipAttribute }) - { - pathBuilder.Insert(0, pathBuilder.Length > 0 ? $"{parent.Relationship.PublicName}." : parent.Relationship.PublicName); - parent = parent._parent; - } - - return pathBuilder.ToString(); - } - } - - private IncludeTreeNode(RelationshipAttribute relationship, IncludeTreeNode? parent) + private IncludeTreeNode(RelationshipAttribute relationship) { Relationship = relationship; - _parent = parent; } public static IncludeTreeNode CreateRoot(ResourceType resourceType) { var relationship = new HiddenRootRelationshipAttribute(resourceType); - return new IncludeTreeNode(relationship, null); + return new IncludeTreeNode(relationship); } public ICollection EnsureChildren(ICollection relationships) @@ -236,7 +241,7 @@ public ICollection EnsureChildren(ICollection [PublicAPI] -public class PaginationParser : QueryExpressionParser +public class PaginationParser : QueryExpressionParser, IPaginationParser { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public PaginationParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) + /// + public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceType) { - ArgumentGuard.NotNull(resourceTypeInScope); - - _resourceTypeInScope = resourceTypeInScope; + ArgumentGuard.NotNull(resourceType); Tokenize(source); - PaginationQueryStringValueExpression expression = ParsePagination(); + PaginationQueryStringValueExpression expression = ParsePagination(resourceType); AssertTokenStackIsEmpty(); return expression; } - protected PaginationQueryStringValueExpression ParsePagination() + protected virtual PaginationQueryStringValueExpression ParsePagination(ResourceType resourceType) { ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); - PaginationElementQueryStringValueExpression element = ParsePaginationElement(); + PaginationElementQueryStringValueExpression element = ParsePaginationElement(resourceType); elementsBuilder.Add(element); while (TokenStack.Any()) { EatSingleCharacterToken(TokenKind.Comma); - element = ParsePaginationElement(); + element = ParsePaginationElement(resourceType); elementsBuilder.Add(element); } return new PaginationQueryStringValueExpression(elementsBuilder.ToImmutable()); } - protected PaginationElementQueryStringValueExpression ParsePaginationElement() + protected virtual PaginationElementQueryStringValueExpression ParsePaginationElement(ResourceType resourceType) { + int position = GetNextTokenPositionOrEnd(); int? number = TryParseNumber(); if (number != null) { - return new PaginationElementQueryStringValueExpression(null, number.Value); + return new PaginationElementQueryStringValueExpression(null, number.Value, position); } - ResourceFieldChainExpression scope = ParseFieldChain(FieldChainRequirements.EndsInToMany, "Number or relationship name expected."); + ResourceFieldChainExpression scope = ParseFieldChain(BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None, resourceType, + "Number or relationship name expected."); EatSingleCharacterToken(TokenKind.Colon); + position = GetNextTokenPositionOrEnd(); number = TryParseNumber(); if (number == null) { - throw new QueryParseException("Number expected."); + throw new QueryParseException("Number expected.", position); } - return new PaginationElementQueryStringValueExpression(scope, number.Value); + return new PaginationElementQueryStringValueExpression(scope, number.Value, position); } - protected int? TryParseNumber() + private int? TryParseNumber() { if (TokenStack.TryPeek(out Token? nextToken)) { @@ -83,13 +78,14 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() if (nextToken.Kind == TokenKind.Minus) { TokenStack.Pop(); + int position = GetNextTokenPositionOrEnd(); if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) { return -number; } - throw new QueryParseException("Digits expected."); + throw new QueryParseException("Digits expected.", position); } if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) @@ -101,9 +97,4 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() return null; } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs new file mode 100644 index 0000000000..5f1d3c1e89 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -0,0 +1,185 @@ +using System.Collections.Immutable; +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// The base class for parsing query string parameters, using the Recursive Descent algorithm. +/// +/// +/// A tokenizer populates a stack of tokens from the source text, which is then recursively popped by various parsing routines. A +/// is expected to be thrown on invalid input. +/// +[PublicAPI] +public abstract class QueryExpressionParser +{ + private int _endOfSourcePosition; + + /// + /// Contains the tokens produced from the source text, after has been called. + /// + /// + /// The various parsing methods typically pop tokens while producing s. + /// + protected Stack TokenStack { get; private set; } = new(); + + /// + /// Enables derived types to throw a when usage of a JSON:API field inside a field chain is not permitted. + /// + protected virtual void ValidateField(ResourceFieldAttribute field, int position) + { + } + + /// + /// Populates from the source text using . + /// + /// + /// To use a custom tokenizer, override this method and consider overriding . + /// + protected virtual void Tokenize(string source) + { + var tokenizer = new QueryTokenizer(source); + TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); + _endOfSourcePosition = source.Length; + } + + /// + /// Parses a dot-separated path of field names into a chain of resource fields, while matching it against the specified pattern. + /// + protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ResourceType resourceType, + string? alternativeErrorMessage) + { + ArgumentGuard.NotNull(pattern); + ArgumentGuard.NotNull(resourceType); + + int startPosition = GetNextTokenPositionOrEnd(); + + string path = EatFieldChain(alternativeErrorMessage); + PatternMatchResult result = pattern.Match(path, resourceType, options); + + if (!result.IsSuccess) + { + string message = result.IsFieldChainError + ? result.FailureMessage + : $"Field chain on resource type '{resourceType}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; + + throw new QueryParseException(message, startPosition + result.FailurePosition); + } + + int chainPosition = 0; + + foreach (ResourceFieldAttribute field in result.FieldChain) + { + ValidateField(field, startPosition + chainPosition); + + chainPosition += field.PublicName.Length + 1; + } + + return new ResourceFieldChainExpression(result.FieldChain.ToImmutableArray()); + } + + private string EatFieldChain(string? alternativeErrorMessage) + { + var pathBuilder = new StringBuilder(); + + while (true) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && token.Value != Keywords.Null) + { + pathBuilder.Append(token.Value); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); + pathBuilder.Append('.'); + } + else + { + return pathBuilder.ToString(); + } + } + else + { + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected.", position); + } + } + } + + /// + /// Consumes a token containing the expected text from the top of . Throws a if a different + /// token kind is at the top, it contains a different text, or if there are no more tokens available. + /// + protected void EatText(string text) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) + { + int position = token?.Position ?? GetNextTokenPositionOrEnd(); + throw new QueryParseException($"{text} expected.", position); + } + } + + /// + /// Consumes the expected token kind from the top of . Throws a if a different token kind is + /// at the top, or if there are no more tokens available. + /// + protected virtual void EatSingleCharacterToken(TokenKind kind) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) + { + char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; + int position = token?.Position ?? GetNextTokenPositionOrEnd(); + throw new QueryParseException($"{ch} expected.", position); + } + } + + /// + /// Gets the zero-based position of the token at the top of , or the position at the end of the source text if there are no more + /// tokens available. + /// + protected int GetNextTokenPositionOrEnd() + { + if (TokenStack.TryPeek(out Token? nextToken)) + { + return nextToken.Position; + } + + return _endOfSourcePosition; + } + + /// + /// Gets the zero-based position of the last field in the specified resource field chain. + /// + protected int GetRelativePositionOfLastFieldInChain(ResourceFieldChainExpression fieldChain) + { + ArgumentGuard.NotNull(fieldChain); + + int position = 0; + + for (int index = 0; index < fieldChain.Fields.Count - 1; index++) + { + position += fieldChain.Fields[index].PublicName.Length + 1; + } + + return position; + } + + /// + /// Throws a when isn't empty. Derived types should call this when parsing has completed, to + /// ensure all input has been processed. + /// + protected void AssertTokenStackIsEmpty() + { + if (TokenStack.Any()) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("End of expression expected.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs new file mode 100644 index 0000000000..8136fb476e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs @@ -0,0 +1,43 @@ +using System.Text; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// The error that is thrown when parsing a query string parameter fails. +/// +[PublicAPI] +public sealed class QueryParseException : Exception +{ + /// + /// Gets the zero-based position in the text of the query string parameter name/value, or at its end, where the failure occurred, or -1 if unavailable. + /// + public int Position { get; } + + public QueryParseException(string message, int position) + : base(message) + { + Position = position; + } + + public QueryParseException(string message, int position, Exception innerException) + : base(message, innerException) + { + Position = position; + } + + public string GetMessageWithPosition(string sourceText) + { + ArgumentGuard.NotNull(sourceText); + + if (Position < 0) + { + return Message; + } + + StringBuilder builder = new(); + builder.Append(Message); + builder.Append($" Failed at position {Position + 1}: {sourceText[..Position]}^{sourceText[Position..]}"); + return builder.ToString(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..2272b94caa --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -0,0 +1,52 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class QueryStringParameterScopeParser : QueryExpressionParser, IQueryStringParameterScopeParser +{ + /// + public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(pattern); + + Tokenize(source); + + QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(resourceType, pattern, options); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual QueryStringParameterScopeExpression ParseQueryStringParameterScope(ResourceType resourceType, FieldChainPattern pattern, + FieldChainPatternMatchOptions options) + { + int position = GetNextTokenPositionOrEnd(); + + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected.", position); + } + + var name = new LiteralConstantExpression(token.Value!); + + ResourceFieldChainExpression? scope = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) + { + TokenStack.Pop(); + + scope = ParseFieldChain(pattern, options, resourceType, null); + + EatSingleCharacterToken(TokenKind.CloseBracket); + } + + return new QueryStringParameterScopeExpression(name, scope); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs similarity index 81% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs rename to src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs index 37f29da58d..9360d95be3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs @@ -2,7 +2,7 @@ using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public sealed class QueryTokenizer @@ -22,7 +22,8 @@ public sealed class QueryTokenizer private readonly string _source; private readonly StringBuilder _textBuffer = new(); - private int _offset; + private int _sourceOffset; + private int? _tokenStartOffset; private bool _isInQuotedSection; public QueryTokenizer(string source) @@ -36,11 +37,14 @@ public IEnumerable EnumerateTokens() { _textBuffer.Clear(); _isInQuotedSection = false; - _offset = 0; + _sourceOffset = 0; + _tokenStartOffset = null; - while (_offset < _source.Length) + while (_sourceOffset < _source.Length) { - char ch = _source[_offset]; + _tokenStartOffset ??= _sourceOffset; + + char ch = _source[_sourceOffset]; if (ch == '\'') { @@ -51,7 +55,7 @@ public IEnumerable EnumerateTokens() if (peeked == '\'') { _textBuffer.Append(ch); - _offset += 2; + _sourceOffset += 2; continue; } @@ -64,7 +68,7 @@ public IEnumerable EnumerateTokens() { if (_textBuffer.Length > 0) { - throw new QueryParseException("Unexpected ' outside text."); + throw new QueryParseException("Unexpected ' outside text.", _sourceOffset); } _isInQuotedSection = true; @@ -83,25 +87,27 @@ public IEnumerable EnumerateTokens() yield return identifierToken; } - yield return new Token(singleCharacterTokenKind.Value); + yield return new Token(singleCharacterTokenKind.Value, _sourceOffset); + + _tokenStartOffset = null; } else { if (ch == ' ' && !_isInQuotedSection) { - throw new QueryParseException("Unexpected whitespace."); + throw new QueryParseException("Unexpected whitespace.", _sourceOffset); } _textBuffer.Append(ch); } } - _offset++; + _sourceOffset++; } if (_isInQuotedSection) { - throw new QueryParseException("' expected."); + throw new QueryParseException("' expected.", _sourceOffset - 1); } Token? lastToken = ProduceTokenFromTextBuffer(false); @@ -119,7 +125,7 @@ private bool IsMinusInsideText(TokenKind kind) private char? PeekChar() { - return _offset + 1 < _source.Length ? _source[_offset + 1] : null; + return _sourceOffset + 1 < _source.Length ? _source[_sourceOffset + 1] : null; } private static TokenKind? TryGetSingleCharacterTokenKind(char ch) @@ -131,9 +137,13 @@ private bool IsMinusInsideText(TokenKind kind) { if (isQuotedText || _textBuffer.Length > 0) { + int tokenStartOffset = _tokenStartOffset!.Value; string text = _textBuffer.ToString(); + _textBuffer.Clear(); - return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text); + _tokenStartOffset = null; + + return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text, tokenStartOffset); } return null; diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs new file mode 100644 index 0000000000..aaf636deca --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -0,0 +1,142 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class SortParser : QueryExpressionParser, ISortParser +{ + /// + public SortExpression Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + Tokenize(source); + + SortExpression expression = ParseSort(resourceType); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual SortExpression ParseSort(ResourceType resourceType) + { + SortElementExpression firstElement = ParseSortElement(resourceType); + + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); + elementsBuilder.Add(firstElement); + + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); + + SortElementExpression nextElement = ParseSortElement(resourceType); + elementsBuilder.Add(nextElement); + } + + return new SortExpression(elementsBuilder.ToImmutable()); + } + + protected virtual SortElementExpression ParseSortElement(ResourceType resourceType) + { + bool isAscending = true; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) + { + TokenStack.Pop(); + isAscending = false; + } + + // 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, ParseFieldChain() fails with an error. We could add optional upcast syntax + // (which would be required in this case) in the future to make it work, if desired. + + QueryExpression target; + + if (TokenStack.TryPeek(out nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + target = ParseFunction(resourceType); + } + else + { + string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; + target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); + } + + return new SortElementExpression(target, isAscending); + } + + protected virtual bool IsFunction(string name) + { + ArgumentGuard.NotNullNorEmpty(name); + + return name == Keywords.Count; + } + + protected virtual FunctionExpression ParseFunction(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Count: + { + return ParseCount(resourceType); + } + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Count function expected.", position); + } + + private CountExpression ParseCount(ResourceType resourceType) + { + EatText(Keywords.Count); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + { + throw new QueryParseException($"Sorting on attribute '{attribute.PublicName}' is not allowed.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs similarity index 50% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs rename to src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index 0cabbcf76e..7bbea5c082 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -2,37 +2,30 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; +/// [PublicAPI] -public class SparseFieldSetParser : QueryExpressionParser +public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceType; - - public SparseFieldSetParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - + /// public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) { ArgumentGuard.NotNull(resourceType); - _resourceType = resourceType; - Tokenize(source); - SparseFieldSetExpression? expression = ParseSparseFieldSet(); + SparseFieldSetExpression? expression = ParseSparseFieldSet(resourceType); AssertTokenStackIsEmpty(); return expression; } - protected SparseFieldSetExpression? ParseSparseFieldSet() + protected virtual SparseFieldSetExpression? ParseSparseFieldSet(ResourceType resourceType) { ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); @@ -43,7 +36,9 @@ public SparseFieldSetParser(Action EatSingleCharacterToken(TokenKind.Comma); } - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); + ResourceFieldChainExpression nextChain = + ParseFieldChain(BuiltInPatterns.SingleField, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); + ResourceFieldAttribute nextField = nextChain.Fields.Single(); fieldSetBuilder.Add(nextField); } @@ -51,12 +46,12 @@ public SparseFieldSetParser(Action return fieldSetBuilder.Any() ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + protected override void ValidateField(ResourceFieldAttribute field, int position) { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); - - _validateSingleFieldCallback?.Invoke(field, _resourceType!, path); - - return ImmutableArray.Create(field); + if (field.IsViewBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Retrieving the {kind} '{field.PublicName}' is not allowed.", position); + } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs similarity index 63% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs rename to src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs index eceb05d211..e24ce9f90e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs @@ -1,12 +1,11 @@ -using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; +/// [PublicAPI] -public class SparseFieldTypeParser : QueryExpressionParser +public class SparseFieldTypeParser : QueryExpressionParser, ISparseFieldTypeParser { private readonly IResourceGraph _resourceGraph; @@ -17,22 +16,25 @@ public SparseFieldTypeParser(IResourceGraph resourceGraph) _resourceGraph = resourceGraph; } + /// public ResourceType Parse(string source) { Tokenize(source); - ResourceType resourceType = ParseSparseFieldTarget(); + ResourceType resourceType = ParseSparseFieldType(); AssertTokenStackIsEmpty(); return resourceType; } - private ResourceType ParseSparseFieldTarget() + protected virtual ResourceType ParseSparseFieldType() { + int position = GetNextTokenPositionOrEnd(); + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { - throw new QueryParseException("Parameter name expected."); + throw new QueryParseException("Parameter name expected.", position); } EatSingleCharacterToken(TokenKind.OpenBracket); @@ -46,28 +48,25 @@ private ResourceType ParseSparseFieldTarget() private ResourceType ParseResourceType() { + int position = GetNextTokenPositionOrEnd(); + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - return GetResourceType(token.Value!); + return GetResourceType(token.Value!, token.Position); } - throw new QueryParseException("Resource type expected."); + throw new QueryParseException("Resource type expected.", position); } - private ResourceType GetResourceType(string publicName) + private ResourceType GetResourceType(string publicName, int position) { ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); if (resourceType == null) { - throw new QueryParseException($"Resource type '{publicName}' does not exist."); + throw new QueryParseException($"Resource type '{publicName}' does not exist.", position); } return resourceType; } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - throw new NotSupportedException(); - } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Parsing/Token.cs new file mode 100644 index 0000000000..4700127e1d --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/Token.cs @@ -0,0 +1,28 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +[PublicAPI] +public class Token +{ + public TokenKind Kind { get; } + public string? Value { get; } + public int Position { get; } + + public Token(TokenKind kind, int position) + { + Kind = kind; + Position = position; + } + + public Token(TokenKind kind, string value, int position) + : this(kind, position) + { + Value = value; + } + + public override string ToString() + { + return Value == null ? $"{Kind} at {Position}" : $"{Kind}: '{Value}' at {Position}"; + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs similarity index 75% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs rename to src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs index f73cbd3418..23fd428bf5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; public enum TokenKind { diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs similarity index 99% rename from src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs rename to src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index e22b4ba86b..fb4920d1a4 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -6,7 +6,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs new file mode 100644 index 0000000000..0182552b8e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IIncludeClauseBuilder +{ + Expression ApplyInclude(IncludeExpression include, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs new file mode 100644 index 0000000000..fdbd55d095 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IOrderClauseBuilder +{ + Expression ApplyOrderBy(SortExpression expression, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs new file mode 100644 index 0000000000..7bf7b6b2f7 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs @@ -0,0 +1,17 @@ +using System.Linq.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Drives conversion from into system trees. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IQueryableBuilder +{ + Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs new file mode 100644 index 0000000000..25e79c4202 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface ISelectClauseBuilder +{ + Expression ApplySelect(FieldSelection selection, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs new file mode 100644 index 0000000000..4016532a09 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into and +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface ISkipTakeClauseBuilder +{ + Expression ApplySkipTake(PaginationExpression expression, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs new file mode 100644 index 0000000000..f9e47cc714 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IWhereClauseBuilder +{ + Expression ApplyWhere(FilterExpression filter, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs similarity index 51% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs index 80b3280355..3b0793f774 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -1,56 +1,40 @@ using System.Linq.Expressions; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// -/// Transforms into calls. -/// +/// [PublicAPI] -public class IncludeClauseBuilder : QueryClauseBuilder +public class IncludeClauseBuilder : QueryClauseBuilder, IIncludeClauseBuilder { private static readonly IncludeChainConverter IncludeChainConverter = new(); - private readonly Expression _source; - private readonly ResourceType _resourceType; - - public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceType resourceType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(resourceType); - - _source = source; - _resourceType = resourceType; - } - - public Expression ApplyInclude(IncludeExpression include) + public virtual Expression ApplyInclude(IncludeExpression include, QueryClauseBuilderContext context) { ArgumentGuard.NotNull(include); - return Visit(include, null); + return Visit(include, context); } - public override Expression VisitInclude(IncludeExpression expression, object? argument) + public override Expression VisitInclude(IncludeExpression expression, QueryClauseBuilderContext context) { // De-duplicate chains coming from derived relationships. HashSet propertyPaths = new(); - ApplyEagerLoads(_resourceType.EagerLoads, null, propertyPaths); + ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths); foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { ProcessRelationshipChain(chain, propertyPaths); } - return ToExpression(propertyPaths); + return ToExpression(context.Source, context.LambdaScope.Parameter.Type, propertyPaths); } - private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) + private static void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) { string? path = null; @@ -64,7 +48,7 @@ private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet eagerLoads, string? pathPrefix, ISet outputPropertyPaths) + private static void ApplyEagerLoads(IEnumerable eagerLoads, string? pathPrefix, ISet outputPropertyPaths) { foreach (EagerLoadAttribute eagerLoad in eagerLoads) { @@ -75,22 +59,22 @@ private void ApplyEagerLoads(IEnumerable eagerLoads, string? } } - private Expression ToExpression(HashSet propertyPaths) + private static Expression ToExpression(Expression source, Type entityType, HashSet propertyPaths) { - Expression source = _source; + Expression expression = source; foreach (string propertyPath in propertyPaths) { - source = IncludeExtensionMethodCall(source, propertyPath); + expression = IncludeExtensionMethodCall(expression, entityType, propertyPath); } - return source; + return expression; } - private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) + private static Expression IncludeExtensionMethodCall(Expression source, Type entityType, string navigationPropertyPath) { Expression navigationExpression = Expression.Constant(navigationPropertyPath); - return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", LambdaScope.Parameter.Type.AsArray(), source, navigationExpression); + return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", entityType.AsArray(), source, navigationExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs new file mode 100644 index 0000000000..3f60ef8e55 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs @@ -0,0 +1,54 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// A scoped lambda expression with a unique name. Disposing the instance releases the claimed name, so it can be reused. +/// +[PublicAPI] +public sealed class LambdaScope : IDisposable +{ + private readonly LambdaScopeFactory _owner; + + /// + /// Gets the lambda parameter. For example, 'person' in: person => person.Account.Name == "Joe". + /// + public ParameterExpression Parameter { get; } + + /// + /// Gets the lambda accessor. For example, 'person.Account' in: person => person.Account.Name == "Joe". + /// + public Expression Accessor { get; } + + private LambdaScope(LambdaScopeFactory owner, ParameterExpression parameter, Expression accessor) + { + _owner = owner; + Parameter = parameter; + Accessor = accessor; + } + + internal static LambdaScope Create(LambdaScopeFactory owner, Type elementType, string parameterName, Expression? accessorExpression = null) + { + ArgumentGuard.NotNull(owner); + ArgumentGuard.NotNull(elementType); + ArgumentGuard.NotNullNorEmpty(parameterName); + + ParameterExpression parameter = Expression.Parameter(elementType, parameterName); + Expression accessor = accessorExpression ?? parameter; + + return new LambdaScope(owner, parameter, accessor); + } + + public LambdaScope WithAccessor(Expression accessorExpression) + { + ArgumentGuard.NotNull(accessorExpression); + + return new LambdaScope(_owner, Parameter, accessorExpression); + } + + public void Dispose() + { + _owner.Release(this); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs new file mode 100644 index 0000000000..cf8a30e1db --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using Humanizer; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Produces lambda parameters with unique names. +/// +[PublicAPI] +public sealed class LambdaScopeFactory +{ + private readonly HashSet _namesInScope = new(); + + /// + /// Finds the next unique lambda parameter name. Dispose the returned scope to release the claimed name, so it can be reused. + /// + public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) + { + ArgumentGuard.NotNull(elementType); + + string parameterName = elementType.Name.Camelize(); + parameterName = EnsureUniqueName(parameterName); + _namesInScope.Add(parameterName); + + return LambdaScope.Create(this, elementType, parameterName, accessorExpression); + } + + private string EnsureUniqueName(string name) + { + if (!_namesInScope.Contains(name)) + { + return name; + } + + int counter = 1; + string alternativeName; + + do + { + counter++; + alternativeName = name + counter; + } + while (_namesInScope.Contains(alternativeName)); + + return alternativeName; + } + + internal void Release(LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(lambdaScope); + + _namesInScope.Remove(lambdaScope.Parameter.Name!); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs new file mode 100644 index 0000000000..09f0c5326e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs @@ -0,0 +1,63 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class OrderClauseBuilder : QueryClauseBuilder, IOrderClauseBuilder +{ + public virtual Expression ApplyOrderBy(SortExpression expression, QueryClauseBuilderContext context) + { + ArgumentGuard.NotNull(expression); + + return Visit(expression, context); + } + + public override Expression VisitSort(SortExpression expression, QueryClauseBuilderContext context) + { + QueryClauseBuilderContext nextContext = context; + + foreach (SortElementExpression sortElement in expression.Elements) + { + Expression sortExpression = Visit(sortElement, nextContext); + nextContext = nextContext.WithSource(sortExpression); + } + + return nextContext.Source; + } + + public override Expression VisitSortElement(SortElementExpression expression, QueryClauseBuilderContext context) + { + Expression body = Visit(expression.Target, context); + LambdaExpression lambda = Expression.Lambda(body, context.LambdaScope.Parameter); + string operationName = GetOperationName(expression.IsAscending, context); + + return ExtensionMethodCall(context.Source, operationName, body.Type, lambda, context); + } + + private static string GetOperationName(bool isAscending, QueryClauseBuilderContext context) + { + bool hasPrecedingSort = false; + + if (context.Source is MethodCallExpression methodCall) + { + hasPrecedingSort = methodCall.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending"; + } + + if (hasPrecedingSort) + { + return isAscending ? "ThenBy" : "ThenByDescending"; + } + + return isAscending ? "OrderBy" : "OrderByDescending"; + } + + private static Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector, + QueryClauseBuilderContext context) + { + Type[] typeArguments = ArrayFactory.Create(context.LambdaScope.Parameter.Type, keyType); + return Expression.Call(context.ExtensionType, operationName, typeArguments, source, keySelector); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs similarity index 71% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs index fdbb3bc0c3..4e2743fa02 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs @@ -3,25 +3,21 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; /// /// Base class for transforming trees into system trees. /// -public abstract class QueryClauseBuilder : QueryExpressionVisitor +public abstract class QueryClauseBuilder : QueryExpressionVisitor { - protected LambdaScope LambdaScope { get; private set; } - - protected QueryClauseBuilder(LambdaScope lambdaScope) + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext argument) { - ArgumentGuard.NotNull(lambdaScope); - - LambdaScope = lambdaScope; + throw new NotSupportedException($"Unknown expression of type '{expression.GetType()}'."); } - public override Expression VisitCount(CountExpression expression, TArgument argument) + public override Expression VisitCount(CountExpression expression, QueryClauseBuilderContext context) { - Expression collectionExpression = Visit(expression.TargetCollection, argument); + Expression collectionExpression = Visit(expression.TargetCollection, context); Expression? propertyExpression = GetCollectionCount(collectionExpression); @@ -59,13 +55,13 @@ public override Expression VisitCount(CountExpression expression, TArgument argu return null; } - public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, QueryClauseBuilderContext context) { MemberExpression? property = null; foreach (ResourceFieldAttribute field in expression.Fields) { - Expression parentAccessor = property ?? LambdaScope.Accessor; + Expression parentAccessor = property ?? context.LambdaScope.Accessor; Type propertyType = field.Property.DeclaringType!; string propertyName = field.Property.Name; @@ -84,24 +80,4 @@ public override Expression VisitResourceFieldChain(ResourceFieldChainExpression return property!; } - - protected TResult WithLambdaScopeAccessor(Expression accessorExpression, Func action) - { - ArgumentGuard.NotNull(accessorExpression); - ArgumentGuard.NotNull(action); - - LambdaScope backupScope = LambdaScope; - - try - { - using (LambdaScope = LambdaScope.WithAccessor(accessorExpression)) - { - return action(); - } - } - finally - { - LambdaScope = backupScope; - } - } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs new file mode 100644 index 0000000000..42dcf80428 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -0,0 +1,88 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Immutable contextual state for *ClauseBuilder types. +/// +[PublicAPI] +public sealed class QueryClauseBuilderContext +{ + /// + /// The source expression to append to. + /// + public Expression Source { get; } + + /// + /// The resource type for . + /// + public ResourceType ResourceType { get; } + + /// + /// The extension type to generate calls on, typically or . + /// + public Type ExtensionType { get; } + + /// + /// The Entity Framework Core entity model. + /// + public IModel EntityModel { get; } + + /// + /// Used to produce unique names for lambda parameters. + /// + public LambdaScopeFactory LambdaScopeFactory { get; } + + /// + /// The lambda expression currently in scope. + /// + public LambdaScope LambdaScope { get; } + + /// + /// The outer driver for building query clauses. + /// + public IQueryableBuilder QueryableBuilder { get; } + + /// + /// Enables to pass custom state that you'd like to transfer between calls. + /// + public object? State { get; } + + public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IModel entityModel, + LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state) + { + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(extensionType); + ArgumentGuard.NotNull(entityModel); + ArgumentGuard.NotNull(lambdaScopeFactory); + ArgumentGuard.NotNull(lambdaScope); + ArgumentGuard.NotNull(queryableBuilder); + + Source = source; + ResourceType = resourceType; + LambdaScope = lambdaScope; + EntityModel = entityModel; + ExtensionType = extensionType; + LambdaScopeFactory = lambdaScopeFactory; + QueryableBuilder = queryableBuilder; + State = state; + } + + public QueryClauseBuilderContext WithSource(Expression source) + { + ArgumentGuard.NotNull(source); + + return new QueryClauseBuilderContext(source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); + } + + public QueryClauseBuilderContext WithLambdaScope(LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(lambdaScope); + + return new QueryClauseBuilderContext(Source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, QueryableBuilder, State); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs new file mode 100644 index 0000000000..bec8329c34 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -0,0 +1,108 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class QueryableBuilder : IQueryableBuilder +{ + private readonly IIncludeClauseBuilder _includeClauseBuilder; + private readonly IWhereClauseBuilder _whereClauseBuilder; + private readonly IOrderClauseBuilder _orderClauseBuilder; + private readonly ISkipTakeClauseBuilder _skipTakeClauseBuilder; + private readonly ISelectClauseBuilder _selectClauseBuilder; + + public QueryableBuilder(IIncludeClauseBuilder includeClauseBuilder, IWhereClauseBuilder whereClauseBuilder, IOrderClauseBuilder orderClauseBuilder, + ISkipTakeClauseBuilder skipTakeClauseBuilder, ISelectClauseBuilder selectClauseBuilder) + { + ArgumentGuard.NotNull(includeClauseBuilder); + ArgumentGuard.NotNull(whereClauseBuilder); + ArgumentGuard.NotNull(orderClauseBuilder); + ArgumentGuard.NotNull(skipTakeClauseBuilder); + ArgumentGuard.NotNull(selectClauseBuilder); + + _includeClauseBuilder = includeClauseBuilder; + _whereClauseBuilder = whereClauseBuilder; + _orderClauseBuilder = orderClauseBuilder; + _skipTakeClauseBuilder = skipTakeClauseBuilder; + _selectClauseBuilder = selectClauseBuilder; + } + + public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context) + { + ArgumentGuard.NotNull(layer); + ArgumentGuard.NotNull(context); + + Expression expression = context.Source; + + if (layer.Include != null) + { + expression = ApplyInclude(expression, layer.Include, layer.ResourceType, context); + } + + if (layer.Filter != null) + { + expression = ApplyFilter(expression, layer.Filter, layer.ResourceType, context); + } + + if (layer.Sort != null) + { + expression = ApplySort(expression, layer.Sort, layer.ResourceType, context); + } + + if (layer.Pagination != null) + { + expression = ApplyPagination(expression, layer.Pagination, layer.ResourceType, context); + } + + if (layer.Selection is { IsEmpty: false }) + { + expression = ApplySelection(expression, layer.Selection, layer.ResourceType, context); + } + + return expression; + } + + protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _includeClauseBuilder.ApplyInclude(include, clauseContext); + } + + protected virtual Expression ApplyFilter(Expression source, FilterExpression filter, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _whereClauseBuilder.ApplyWhere(filter, clauseContext); + } + + protected virtual Expression ApplySort(Expression source, SortExpression sort, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _orderClauseBuilder.ApplyOrderBy(sort, clauseContext); + } + + protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _skipTakeClauseBuilder.ApplySkipTake(pagination, clauseContext); + } + + protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _selectClauseBuilder.ApplySelect(selection, clauseContext); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs new file mode 100644 index 0000000000..4659cca875 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs @@ -0,0 +1,82 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Immutable contextual state for . +/// +[PublicAPI] +public sealed class QueryableBuilderContext +{ + /// + /// The source expression to append to. + /// + public Expression Source { get; } + + /// + /// The element type for . + /// + public Type ElementType { get; } + + /// + /// The extension type to generate calls on, typically or . + /// + public Type ExtensionType { get; } + + /// + /// The Entity Framework Core entity model. + /// + public IModel EntityModel { get; } + + /// + /// Used to produce unique names for lambda parameters. + /// + public LambdaScopeFactory LambdaScopeFactory { get; } + + /// + /// Enables to pass custom state that you'd like to transfer between calls. + /// + public object? State { get; } + + public QueryableBuilderContext(Expression source, Type elementType, Type extensionType, IModel entityModel, LambdaScopeFactory lambdaScopeFactory, + object? state) + { + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(elementType); + ArgumentGuard.NotNull(extensionType); + ArgumentGuard.NotNull(entityModel); + ArgumentGuard.NotNull(lambdaScopeFactory); + + Source = source; + ElementType = elementType; + ExtensionType = extensionType; + EntityModel = entityModel; + LambdaScopeFactory = lambdaScopeFactory; + State = state; + } + + public static QueryableBuilderContext CreateRoot(IQueryable source, Type extensionType, IModel model, object? state) + { + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(extensionType); + ArgumentGuard.NotNull(model); + + var lambdaScopeFactory = new LambdaScopeFactory(); + + return new QueryableBuilderContext(source.Expression, source.ElementType, extensionType, model, lambdaScopeFactory, state); + } + + public QueryClauseBuilderContext CreateClauseContext(IQueryableBuilder queryableBuilder, Expression source, ResourceType resourceType, + LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(queryableBuilder); + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(lambdaScope); + + return new QueryClauseBuilderContext(source, resourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, queryableBuilder, State); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs similarity index 74% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index d206bd8b17..ce491304ef 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -2,68 +2,50 @@ using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore.Metadata; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// -/// Transforms into -/// calls. -/// +/// [PublicAPI] -public class SelectClauseBuilder : QueryClauseBuilder +public class SelectClauseBuilder : QueryClauseBuilder, ISelectClauseBuilder { 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); - private readonly Expression _source; - private readonly IModel _entityModel; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; private readonly IResourceFactory _resourceFactory; - public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory) - : base(lambdaScope) + public SelectClauseBuilder(IResourceFactory resourceFactory) { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(entityModel); - ArgumentGuard.NotNull(extensionType); - ArgumentGuard.NotNull(nameFactory); ArgumentGuard.NotNull(resourceFactory); - _source = source; - _entityModel = entityModel; - _extensionType = extensionType; - _nameFactory = nameFactory; _resourceFactory = resourceFactory; } - public Expression ApplySelect(FieldSelection selection, ResourceType resourceType) + public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuilderContext context) { ArgumentGuard.NotNull(selection); - Expression bodyInitializer = CreateLambdaBodyInitializer(selection, resourceType, LambdaScope, false); + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, context.LambdaScope, false, context); - LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); + LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter); - return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); + return SelectExtensionMethodCall(context.ExtensionType, context.Source, context.LambdaScope.Parameter.Type, lambda); } private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, - bool lambdaAccessorRequiresTestForNull) + bool lambdaAccessorRequiresTestForNull, QueryClauseBuilderContext context) { - IEntityType entityType = _entityModel.FindEntityType(resourceType.ClrType)!; + IEntityType entityType = context.EntityModel.FindEntityType(resourceType.ClrType)!; IEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); Expression bodyInitializer = concreteEntityTypes.Length > 1 - ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope) - : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope); + ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope, context) + : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope, context); if (!lambdaAccessorRequiresTestForNull) { @@ -74,7 +56,7 @@ private Expression CreateLambdaBodyInitializer(FieldSelection selection, Resourc } private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType, - IEnumerable concreteEntityTypes, LambdaScope lambdaScope) + IEnumerable concreteEntityTypes, LambdaScope lambdaScope, QueryClauseBuilderContext context) { IReadOnlySet resourceTypes = selection.GetResourceTypes(); Expression rootCondition = lambdaScope.Accessor; @@ -89,9 +71,10 @@ private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection se if (!fieldSelectors.IsEmpty) { - ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType); + ICollection propertySelectors = + ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType, context.EntityModel); - MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)) + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope, context)) .Cast().ToArray(); NewExpression createInstance = _resourceFactory.CreateNewExpression(entityType.ClrType); @@ -118,19 +101,21 @@ private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod); } - private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope) + private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, + QueryClauseBuilderContext context) { FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); - ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type); + ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type, context.EntityModel); - MemberBinding[] propertyAssignments = - propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope, context)) + .Cast().ToArray(); NewExpression createInstance = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); return Expression.MemberInit(createInstance, propertyAssignments); } - private ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType) + private static ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType, + IModel entityModel) { var propertySelectors = new Dictionary(); @@ -139,7 +124,7 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. // And only selecting relationships implicitly means to fetch all scalar properties as well. - IncludeAllScalarProperties(elementType, propertySelectors); + IncludeAllScalarProperties(elementType, propertySelectors, entityModel); } IncludeFields(fieldSelectors, propertySelectors); @@ -148,9 +133,9 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe return propertySelectors.Values; } - private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) + private static void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors, IModel entityModel) { - IEntityType entityType = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEntityType entityType = entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); foreach (IProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) { @@ -194,7 +179,7 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary +[PublicAPI] +public class SkipTakeClauseBuilder : QueryClauseBuilder, ISkipTakeClauseBuilder +{ + public virtual Expression ApplySkipTake(PaginationExpression expression, QueryClauseBuilderContext context) + { + ArgumentGuard.NotNull(expression); + + return Visit(expression, context); + } + + public override Expression VisitPagination(PaginationExpression expression, QueryClauseBuilderContext context) + { + Expression skipTakeExpression = context.Source; + + if (expression.PageSize != null) + { + int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; + + if (skipValue > 0) + { + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue, context); + } + + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value, context); + } + + return skipTakeExpression; + } + + private static Expression ExtensionMethodCall(Expression source, string operationName, int value, QueryClauseBuilderContext context) + { + Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); + + return Expression.Call(context.ExtensionType, operationName, context.LambdaScope.Parameter.Type.AsArray(), source, constant); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs similarity index 68% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index c2e8407c8e..981b2da1a3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -1,61 +1,44 @@ using System.Collections; using System.Linq.Expressions; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Internal; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// -/// Transforms into -/// calls. -/// +/// [PublicAPI] -public class WhereClauseBuilder : QueryClauseBuilder +public class WhereClauseBuilder : QueryClauseBuilder, IWhereClauseBuilder { private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); - private readonly Expression _source; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - - public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(extensionType); - ArgumentGuard.NotNull(nameFactory); - - _source = source; - _extensionType = extensionType; - _nameFactory = nameFactory; - } - - public Expression ApplyWhere(FilterExpression filter) + public virtual Expression ApplyWhere(FilterExpression filter, QueryClauseBuilderContext context) { ArgumentGuard.NotNull(filter); - LambdaExpression lambda = GetPredicateLambda(filter); + LambdaExpression lambda = GetPredicateLambda(filter, context); - return WhereExtensionMethodCall(lambda); + return WhereExtensionMethodCall(lambda, context); } - private LambdaExpression GetPredicateLambda(FilterExpression filter) + private LambdaExpression GetPredicateLambda(FilterExpression filter, QueryClauseBuilderContext context) { - Expression body = Visit(filter, null); - return Expression.Lambda(body, LambdaScope.Parameter); + Expression body = Visit(filter, context); + return Expression.Lambda(body, context.LambdaScope.Parameter); } - private Expression WhereExtensionMethodCall(LambdaExpression predicate) + private static Expression WhereExtensionMethodCall(LambdaExpression predicate, QueryClauseBuilderContext context) { - return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); + return Expression.Call(context.ExtensionType, "Where", context.LambdaScope.Parameter.Type.AsArray(), context.Source, predicate); } - public override Expression VisitHas(HasExpression expression, object? argument) + public override Expression VisitHas(HasExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetCollection, argument); + Expression property = Visit(expression.TargetCollection, context); Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); @@ -68,11 +51,14 @@ public override Expression VisitHas(HasExpression expression, object? argument) if (expression.Filter != null) { - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); - using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType); + ResourceType resourceType = ((HasManyAttribute)expression.TargetCollection.Fields[^1]).RightType; + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType); - var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory); - predicate = builder.GetPredicateLambda(expression.Filter); + var nestedContext = new QueryClauseBuilderContext(property, resourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + lambdaScope, context.QueryableBuilder, context.State); + + predicate = GetPredicateLambda(expression.Filter, nestedContext); } return AnyExtensionMethodCall(elementType, property, predicate); @@ -85,9 +71,9 @@ private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Exp : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); } - public override Expression VisitIsType(IsTypeExpression expression, object? argument) + public override Expression VisitIsType(IsTypeExpression expression, QueryClauseBuilderContext context) { - Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, argument) : LambdaScope.Accessor; + Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, context) : context.LambdaScope.Accessor; TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType); if (expression.Child == null) @@ -96,21 +82,23 @@ public override Expression VisitIsType(IsTypeExpression expression, object? argu } UnaryExpression derivedAccessor = Expression.Convert(property, expression.DerivedType.ClrType); - Expression filter = WithLambdaScopeAccessor(derivedAccessor, () => Visit(expression.Child, argument)); + + QueryClauseBuilderContext derivedContext = context.WithLambdaScope(context.LambdaScope.WithAccessor(derivedAccessor)); + Expression filter = Visit(expression.Child, derivedContext); return Expression.AndAlso(typeCheck, filter); } - public override Expression VisitMatchText(MatchTextExpression expression, object? argument) + public override Expression VisitMatchText(MatchTextExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetAttribute, argument); + Expression property = Visit(expression.TargetAttribute, context); if (property.Type != typeof(string)) { throw new InvalidOperationException("Expression must be a string."); } - Expression text = Visit(expression.TextValue, property.Type); + Expression text = Visit(expression.TextValue, context); if (expression.MatchKind == TextMatchKind.StartsWith) { @@ -125,9 +113,9 @@ public override Expression VisitMatchText(MatchTextExpression expression, object return Expression.Call(property, "Contains", null, text); } - public override Expression VisitAny(AnyExpression expression, object? argument) + public override Expression VisitAny(AnyExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetAttribute, argument); + Expression property = Visit(expression.TargetAttribute, context); var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; @@ -145,9 +133,9 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); } - public override Expression VisitLogical(LogicalExpression expression, object? argument) + public override Expression VisitLogical(LogicalExpression expression, QueryClauseBuilderContext context) { - var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); + var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, context))); if (expression.Operator == LogicalOperator.And) { @@ -178,18 +166,18 @@ private static BinaryExpression Compose(Queue argumentQueue, Func).MakeGenericType(leftType); } - Type? rightType = TryResolveFixedType(right); + Type? rightType = TryResolveFixedType(right, context); if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) { @@ -226,13 +214,13 @@ private Type ResolveCommonType(QueryExpression left, QueryExpression right) return leftType; } - private Type ResolveFixedType(QueryExpression expression) + private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderContext context) { - Expression result = Visit(expression, null); + Expression result = Visit(expression, context); return result.Type; } - private Type? TryResolveFixedType(QueryExpression expression) + private Type? TryResolveFixedType(QueryExpression expression, QueryClauseBuilderContext context) { if (expression is CountExpression) { @@ -241,18 +229,18 @@ private Type ResolveFixedType(QueryExpression expression) if (expression is ResourceFieldChainExpression chain) { - Expression child = Visit(chain, null); + Expression child = Visit(chain, context); return child.Type; } return null; } - private static Expression WrapInConvert(Expression expression, Type? targetType) + private static Expression WrapInConvert(Expression expression, Type targetType) { try { - return targetType != null && expression.Type != targetType ? Expression.Convert(expression, targetType) : expression; + return expression.Type != targetType ? Expression.Convert(expression, targetType) : expression; } catch (InvalidOperationException exception) { @@ -260,12 +248,12 @@ private static Expression WrapInConvert(Expression expression, Type? targetType) } } - public override Expression VisitNullConstant(NullConstantExpression expression, object? argument) + public override Expression VisitNullConstant(NullConstantExpression expression, QueryClauseBuilderContext context) { return NullConstant; } - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, object? argument) + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, QueryClauseBuilderContext context) { Type type = expression.TypedValue.GetType(); return expression.TypedValue.CreateTupleAccessExpressionForConstant(type); diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs similarity index 99% rename from src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs rename to src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs index ab1edf9f9e..57df16c62b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// public sealed class SparseFieldSetCache : ISparseFieldSetCache diff --git a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/SystemExpressionExtensions.cs similarity index 96% rename from src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs rename to src/JsonApiDotNetCore/Queries/SystemExpressionExtensions.cs index 9c77f53938..ef81aece33 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/SystemExpressionExtensions.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using System.Reflection; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; internal static class SystemExpressionExtensions { diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs new file mode 100644 index 0000000000..52e31c8dc7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +[PublicAPI] +public static class BuiltInPatterns +{ + public static FieldChainPattern SingleField { get; } = FieldChainPattern.Parse("F"); + public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); + public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A"); + public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]"); + public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M"); + public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*M"); +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs new file mode 100644 index 0000000000..8156e1474e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// The exception that is thrown when the format of a dot-separated resource field chain is invalid. +/// +internal sealed class FieldChainFormatException : FormatException +{ + /// + /// Gets the zero-based error position in the field chain, or at its end. + /// + public int Position { get; } + + public FieldChainFormatException(int position, string message) + : base(message) + { + Position = position; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs new file mode 100644 index 0000000000..e6dac0b258 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs @@ -0,0 +1,34 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Parses a dot-separated resource field chain from text into a list of field names. +/// +internal sealed class FieldChainParser +{ + public IEnumerable Parse(string source) + { + ArgumentGuard.NotNull(source); + + if (source != string.Empty) + { + var fields = new List(source.Split('.')); + int position = 0; + + foreach (string field in fields) + { + string trimmed = field.Trim(); + + if (field.Length == 0 || trimmed.Length != field.Length) + { + throw new FieldChainFormatException(position, "Field name expected."); + } + + position += field.Length + 1; + } + + return fields; + } + + return Array.Empty(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs new file mode 100644 index 0000000000..4928984030 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -0,0 +1,168 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// A pattern that can be matched against a dot-separated resource field chain. +/// +[PublicAPI] +public sealed class FieldChainPattern +{ + /// + /// Gets the set of possible resource field types. + /// + internal FieldTypes Choices { get; } + + /// + /// Indicates whether this pattern segment must match at least one resource field. + /// + internal bool AtLeastOne { get; } + + /// + /// Indicates whether this pattern can match multiple resource fields. + /// + internal bool AtMostOne { get; } + + /// + /// Gets the next pattern segment in the chain, or null if at the end. + /// + internal FieldChainPattern? Next { get; } + + internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, FieldChainPattern? next) + { + if (choices == FieldTypes.None) + { + throw new ArgumentException("The set of choices cannot be empty.", nameof(choices)); + } + + Choices = choices; + AtLeastOne = atLeastOne; + AtMostOne = atMostOne; + Next = next; + } + + /// + /// Creates a pattern from the specified text that can be matched against. + /// + /// + /// Patterns are similar to regular expressions, but a lot simpler. They consist of a sequence of terms. A term can be a single character or a character + /// choice, optionally followed by a quantifier. + ///

+ /// The following characters can be used: + /// + /// + /// M + /// + /// Matches a to-many relationship. + /// + /// O + /// + /// Matches a to-one relationship. + /// + /// R + /// + /// Matches a relationship. + /// + /// A + /// + /// Matches an attribute. + /// + /// F + /// + /// Matches a field. + /// + /// + /// + ///

+ ///

+ /// A character choice contains a set of characters, surrounded by brackets. One of the choices must match. For example, "[MO]" matches a relationship, + /// but not at attribute. + ///

+ /// A quantifier is used to indicate how many times its term directly to the left can occur. + /// + /// + /// ? + /// + /// Matches its term zero or one times. + /// + /// * + /// + /// Matches its term zero or more times. + /// + /// + + /// + /// Matches its term one or more times. + /// + /// + /// + /// + /// For example, the pattern "M?O*A" matches "children.parent.name", "parent.parent.name" and "name". + /// + ///
+ /// + /// The pattern is invalid. + /// + public static FieldChainPattern Parse(string pattern) + { + var parser = new PatternParser(); + return parser.Parse(pattern); + } + + /// + /// Matches the specified resource field chain against this pattern. + /// + /// + /// The dot-separated chain of resource field names. + /// + /// + /// The parent resource type to start matching from. + /// + /// + /// Match options, defaults to . + /// + /// + /// When provided, logs the matching steps at level. + /// + /// + /// The match result. + /// + public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, + ILoggerFactory? loggerFactory = null) + { + ArgumentGuard.NotNull(fieldChain); + ArgumentGuard.NotNull(resourceType); + + ILogger logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + var matcher = new PatternMatcher(this, options, logger); + return matcher.Match(fieldChain, resourceType); + } + + /// + /// Returns only the first segment of this pattern chain. Used for diagnostic messages. + /// + internal FieldChainPattern WithoutNext() + { + return Next == null ? this : new FieldChainPattern(Choices, AtLeastOne, AtMostOne, null); + } + + /// + /// Gets the text representation of this pattern. + /// + public override string ToString() + { + var formatter = new PatternTextFormatter(this); + return formatter.Format(); + } + + /// + /// Gets a human-readable description of this pattern. + /// + public string GetDescription() + { + var formatter = new PatternDescriptionFormatter(this); + return formatter.Format(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs new file mode 100644 index 0000000000..645f53ef50 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Indicates how to perform matching a pattern against a resource field chain. +/// +[Flags] +public enum FieldChainPatternMatchOptions +{ + /// + /// Specifies that no options are set. + /// + None = 0, + + /// + /// Specifies to include fields on derived types in the search for a matching field. + /// + AllowDerivedTypes = 1 +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs new file mode 100644 index 0000000000..8c18c4448e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +internal static class FieldTypeExtensions +{ + public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool pluralize, bool prefix) + { + int startOffset = builder.Length; + + if (choices.HasFlag(FieldTypes.ToManyRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("to-many relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.ToOneRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("to-one relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Attribute) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("attribute", pluralize, prefix, true, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Relationship) && !choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("field", pluralize, prefix, false, builder, startOffset); + } + } + + private static void WriteChoice(string typeText, bool pluralize, bool prefix, bool isAttribute, StringBuilder builder, int startOffset) + { + if (builder.Length > startOffset) + { + builder.Append(" or "); + } + + if (prefix && !pluralize) + { + builder.Append(isAttribute ? "an " : "a "); + } + + builder.Append(typeText); + + if (pluralize) + { + builder.Append('s'); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs new file mode 100644 index 0000000000..8011ec3ec4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +[Flags] +internal enum FieldTypes +{ + None = 0, + Attribute = 1, + ToOneRelationship = 1 << 1, + ToManyRelationship = 1 << 2, + Relationship = ToOneRelationship | ToManyRelationship, + Field = Attribute | Relationship +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs new file mode 100644 index 0000000000..8fe555b2f3 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs @@ -0,0 +1,158 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Indicates a failure to match a pattern against a resource field chain. +/// +internal sealed class MatchError +{ + /// + /// Gets the match failure message. + /// + public string Message { get; } + + /// + /// Gets the zero-based position in the resource field chain, or at its end, where the failure occurred. + /// + public int Position { get; } + + /// + /// Indicates whether this error occurred due to an invalid field chain, irrespective of greedy matching. + /// + public bool IsFieldChainError { get; } + + private MatchError(string message, int position, bool isFieldChainError) + { + Message = message; + Position = position; + IsFieldChainError = isFieldChainError; + } + + public static MatchError CreateForBrokenFieldChain(FieldChainFormatException exception) + { + return new MatchError(exception.Message, exception.Position, true); + } + + public static MatchError CreateForUnknownField(int position, ResourceType? resourceType, string publicName, bool allowDerivedTypes) + { + bool hasDerivedTypes = allowDerivedTypes && resourceType != null && resourceType.DirectlyDerivedTypes.Any(); + + var builder = new MessageBuilder(); + + builder.WriteDoesNotExist(publicName); + builder.WriteResourceType(resourceType); + builder.WriteOrDerivedTypes(hasDerivedTypes); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, true); + } + + public static MatchError CreateForMultipleDerivedTypes(int position, ResourceType resourceType, string publicName) + { + string message = $"Field '{publicName}' is defined on multiple types that derive from resource type '{resourceType}'."; + return new MatchError(message, position, true); + } + + public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? resourceType, FieldTypes choices) + { + var builder = new MessageBuilder(); + + builder.WriteChoices(choices); + builder.WriteResourceType(resourceType); + builder.WriteExpected(); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, false); + } + + public static MatchError CreateForTooMuchInput(int position, ResourceType? resourceType, FieldTypes choices) + { + var builder = new MessageBuilder(); + + builder.WriteEndOfChain(); + + if (choices != FieldTypes.None) + { + builder.WriteOr(); + builder.WriteChoices(choices); + builder.WriteResourceType(resourceType); + } + + builder.WriteExpected(); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, false); + } + + public override string ToString() + { + return Message; + } + + private sealed class MessageBuilder + { + private readonly StringBuilder _builder = new(); + + public void WriteDoesNotExist(string publicName) + { + _builder.Append($"Field '{publicName}' does not exist"); + } + + public void WriteOrDerivedTypes(bool hasDerivedTypes) + { + if (hasDerivedTypes) + { + _builder.Append(" or any of its derived types"); + } + } + + public void WriteEndOfChain() + { + _builder.Append("End of field chain"); + } + + public void WriteOr() + { + _builder.Append(" or "); + } + + public void WriteChoices(FieldTypes choices) + { + bool firstCharToUpper = _builder.Length == 0; + choices.WriteTo(_builder, false, false); + + if (firstCharToUpper && _builder.Length > 0) + { + _builder[0] = char.ToUpperInvariant(_builder[0]); + } + } + + public void WriteResourceType(ResourceType? resourceType) + { + if (resourceType != null) + { + _builder.Append($" on resource type '{resourceType}'"); + } + } + + public void WriteExpected() + { + _builder.Append(" expected"); + } + + public void WriteEnd() + { + _builder.Append('.'); + } + + public override string ToString() + { + return _builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs new file mode 100644 index 0000000000..90255191d7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -0,0 +1,282 @@ +using System.Collections.Immutable; +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Immutable intermediate state, used while matching a resource field chain against a pattern. +/// +internal sealed class MatchState +{ + /// + /// The successful parent match. Chaining together with those of parents produces the full match. + /// + private readonly MatchState? _parentMatch; + + /// + /// The remaining chain of pattern segments. The first segment is being matched against. + /// + public FieldChainPattern? Pattern { get; } + + /// + /// The resource type to find the next field on. + /// + public ResourceType? ResourceType { get; } + + /// + /// The fields matched against this pattern segment. + /// + public IImmutableList FieldsMatched { get; } + + /// + /// The remaining fields to be matched against the remaining pattern chain. + /// + public LinkedListNode? FieldsRemaining { get; } + + /// + /// The error in case matching this pattern segment failed. + /// + public MatchError? Error { get; } + + private MatchState(FieldChainPattern? pattern, ResourceType? resourceType, IImmutableList fieldsMatched, + LinkedListNode? fieldsRemaining, MatchError? error, MatchState? parentMatch) + { + Pattern = pattern; + ResourceType = resourceType; + FieldsMatched = fieldsMatched; + FieldsRemaining = fieldsRemaining; + Error = error; + _parentMatch = parentMatch; + } + + public static MatchState Create(FieldChainPattern pattern, string fieldChainText, ResourceType resourceType) + { + ArgumentGuard.NotNull(pattern); + ArgumentGuard.NotNull(fieldChainText); + ArgumentGuard.NotNull(resourceType); + + try + { + var parser = new FieldChainParser(); + IEnumerable fieldChain = parser.Parse(fieldChainText); + + LinkedListNode? remainingHead = new LinkedList(fieldChain).First; + return new MatchState(pattern, resourceType, ImmutableArray.Empty, remainingHead, null, null); + } + catch (FieldChainFormatException exception) + { + var error = MatchError.CreateForBrokenFieldChain(exception); + return new MatchState(pattern, resourceType, ImmutableArray.Empty, null, error, null); + } + } + + /// + /// Returns a new state for successfully matching the top-level remaining field. Moves one position forward in the resource field chain. + /// + public MatchState SuccessMoveForwardOneField(ResourceFieldAttribute matchedValue) + { + ArgumentGuard.NotNull(matchedValue); + AssertIsSuccess(this); + + IImmutableList fieldsMatched = FieldsMatched.Add(matchedValue); + LinkedListNode? fieldsRemaining = FieldsRemaining!.Next; + ResourceType? resourceType = matchedValue is RelationshipAttribute relationship ? relationship.RightType : null; + + return new MatchState(Pattern, resourceType, fieldsMatched, fieldsRemaining, null, _parentMatch); + } + + /// + /// Returns a new state for matching the next pattern segment. + /// + public MatchState SuccessMoveToNextPattern() + { + AssertIsSuccess(this); + AssertHasPattern(); + + return new MatchState(Pattern!.Next, ResourceType, ImmutableArray.Empty, FieldsRemaining, null, this); + } + + /// + /// Returns a new state for match failure due to an unknown field. + /// + public MatchState FailureForUnknownField(string publicName, bool allowDerivedTypes) + { + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForUnknownField(position, ResourceType, publicName, allowDerivedTypes); + + return Failure(error); + } + + /// + /// Returns a new state for match failure because the field exists on multiple derived types. + /// + public MatchState FailureForMultipleDerivedTypes(string publicName) + { + AssertHasResourceType(); + + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForMultipleDerivedTypes(position, ResourceType!, publicName); + + return Failure(error); + } + + /// + /// Returns a new state for match failure because the field type is not one of the pattern choices. + /// + public MatchState FailureForFieldTypeMismatch(FieldTypes choices, FieldTypes chosenFieldType) + { + FieldTypes allChoices = IncludeChoicesFromParentMatch(choices); + int position = GetAbsolutePosition(chosenFieldType != FieldTypes.None); + var error = MatchError.CreateForFieldTypeMismatch(position, ResourceType, allChoices); + + return Failure(error); + } + + /// + /// Combines the choices of this pattern segment with choices from parent matches, if they can match more. + /// + private FieldTypes IncludeChoicesFromParentMatch(FieldTypes choices) + { + if (choices == FieldTypes.Field) + { + // We already match everything, there's no point in looking deeper. + return choices; + } + + if (_parentMatch is { Pattern: not null }) + { + // The choices from the parent pattern segment are available when: + // - The parent pattern can match multiple times. + // - The parent pattern is optional and matched nothing. + if (!_parentMatch.Pattern.AtMostOne || (!_parentMatch.Pattern.AtLeastOne && _parentMatch.FieldsMatched.Count == 0)) + { + FieldTypes mergedChoices = choices | _parentMatch.Pattern.Choices; + + // If the parent pattern didn't match anything, look deeper. + if (_parentMatch.FieldsMatched.Count == 0) + { + mergedChoices = _parentMatch.IncludeChoicesFromParentMatch(mergedChoices); + } + + return mergedChoices; + } + } + + return choices; + } + + /// + /// Returns a new state for match failure because the resource field chain contains more fields than expected. + /// + public MatchState FailureForTooMuchInput() + { + FieldTypes parentChoices = IncludeChoicesFromParentMatch(FieldTypes.None); + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.ResourceType, parentChoices); + + return Failure(error); + } + + private MatchState Failure(MatchError error) + { + return new MatchState(Pattern, ResourceType, FieldsMatched, FieldsRemaining, error, _parentMatch); + } + + private int GetAbsolutePosition(bool hasLeadingDot) + { + int length = 0; + MatchState? currentState = this; + + while (currentState != null) + { + length += currentState.FieldsMatched.Sum(field => field.PublicName.Length + 1); + currentState = currentState._parentMatch; + } + + length = length > 0 ? length - 1 : 0; + + if (length > 0 && hasLeadingDot) + { + length++; + } + + return length; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + if (FieldsMatched.Count == 0 && FieldsRemaining == null && Pattern == null) + { + builder.Append("EMPTY"); + } + else + { + builder.Append(Error == null ? "SUCCESS: " : "FAILED: "); + builder.Append("Matched '"); + builder.Append(string.Join('.', FieldsMatched)); + builder.Append("' against '"); + builder.Append(Pattern?.WithoutNext()); + builder.Append("' with remaining '"); + builder.Append(string.Join('.', FieldsRemaining.ToEnumerable())); + builder.Append('\''); + } + + if (_parentMatch != null) + { + builder.Append(" -> "); + builder.Append(_parentMatch); + } + + return builder.ToString(); + } + + public IReadOnlyList GetAllFieldsMatched() + { + Stack> matchStack = new(); + MatchState? current = this; + + while (current != null) + { + matchStack.Push(current.FieldsMatched); + current = current._parentMatch; + } + + List fields = new(); + + while (matchStack.Count > 0) + { + IImmutableList matches = matchStack.Pop(); + fields.AddRange(matches); + } + + return fields; + } + + private static void AssertIsSuccess(MatchState state) + { + if (state.Error != null) + { + throw new InvalidOperationException($"Internal error: Expected successful match, but found error: {state.Error}"); + } + } + + private void AssertHasResourceType() + { + if (ResourceType == null) + { + throw new InvalidOperationException("Internal error: Resource type is unavailable."); + } + } + + private void AssertHasPattern() + { + if (Pattern == null) + { + throw new InvalidOperationException("Internal error: Pattern chain is unavailable."); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs new file mode 100644 index 0000000000..63c6c67876 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Logging; + +#pragma warning disable CA2254 // Template should be a static expression + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Logs the pattern matching steps at level. +/// +internal sealed class MatchTraceScope : IDisposable +{ + private readonly FieldChainPattern? _pattern; + private readonly bool _isEnabled; + private readonly ILogger _logger; + private readonly int _indentDepth; + private MatchState? _endState; + + private MatchTraceScope(FieldChainPattern? pattern, bool isEnabled, ILogger logger, int indentDepth) + { + _pattern = pattern; + _isEnabled = isEnabled; + _logger = logger; + _indentDepth = indentDepth; + } + + public static MatchTraceScope CreateRoot(MatchState startState, ILogger logger) + { + ArgumentGuard.NotNull(startState); + ArgumentGuard.NotNull(logger); + + bool isEnabled = logger.IsEnabled(LogLevel.Trace); + + if (isEnabled) + { + string fieldsRemaining = FormatFieldsRemaining(startState); + string message = $"Start matching pattern '{startState.Pattern}' against the complete chain '{fieldsRemaining}'"; + logger.LogTrace(message); + } + + return new MatchTraceScope(startState.Pattern, isEnabled, logger, 0); + } + + public MatchTraceScope CreateChild(MatchState startState) + { + ArgumentGuard.NotNull(startState); + + int indentDepth = _indentDepth + 1; + FieldChainPattern? patternSegment = startState.Pattern?.WithoutNext(); + + if (_isEnabled) + { + string fieldsRemaining = FormatFieldsRemaining(startState); + LogMessage($"Start matching '{patternSegment}' against the remaining chain '{fieldsRemaining}'"); + } + + return new MatchTraceScope(patternSegment, _isEnabled, _logger, indentDepth); + } + + public void LogMatchResult(MatchState resultState) + { + ArgumentGuard.NotNull(resultState); + + if (_isEnabled) + { + if (resultState.Error == null) + { + string fieldsMatched = FormatFieldsMatched(resultState); + LogMessage($"Match '{_pattern}' against '{fieldsMatched}': Success"); + } + else + { + List chain = new(resultState.FieldsMatched.Select(attribute => attribute.PublicName)); + + if (resultState.FieldsRemaining != null) + { + chain.Add(resultState.FieldsRemaining.Value); + } + + string chainText = string.Join('.', chain); + LogMessage($"Match '{_pattern}' against '{chainText}': Failed"); + } + } + } + + public void LogBacktrackTo(MatchState backtrackState) + { + ArgumentGuard.NotNull(backtrackState); + + if (_isEnabled) + { + string fieldsMatched = FormatFieldsMatched(backtrackState); + LogMessage($"Backtracking to successful match against '{fieldsMatched}'"); + } + } + + public void SetResult(MatchState endState) + { + ArgumentGuard.NotNull(endState); + + _endState = endState; + } + + public void Dispose() + { + if (_endState == null) + { + throw new InvalidOperationException("Internal error: End state must be set before leaving trace scope."); + } + + if (_isEnabled) + { + LogMessage(_endState.Error == null ? "Matching completed with success" : "Matching completed with failure"); + } + } + + private static string FormatFieldsRemaining(MatchState state) + { + return string.Join('.', state.FieldsRemaining.ToEnumerable()); + } + + private static string FormatFieldsMatched(MatchState state) + { + return string.Join('.', state.FieldsMatched); + } + + private void LogMessage(string message) + { + string indent = new(' ', _indentDepth * 2); + _logger.LogTrace($"{indent}{message}"); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs new file mode 100644 index 0000000000..841dbb2050 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Formats a chain of segments into a human-readable description. +/// +internal sealed class PatternDescriptionFormatter +{ + private readonly FieldChainPattern _pattern; + + public PatternDescriptionFormatter(FieldChainPattern pattern) + { + ArgumentGuard.NotNull(pattern); + + _pattern = pattern; + } + + public string Format() + { + FieldChainPattern? current = _pattern; + var builder = new StringBuilder(); + + do + { + WriteSeparator(builder); + WriteQuantifier(current.AtLeastOne, current.AtMostOne, builder); + WriteChoices(current, builder); + + current = current.Next; + } + while (current != null); + + return builder.ToString(); + } + + private static void WriteSeparator(StringBuilder builder) + { + if (builder.Length > 0) + { + builder.Append(", followed by "); + } + } + + private static void WriteQuantifier(bool atLeastOne, bool atMostOne, StringBuilder builder) + { + if (!atLeastOne) + { + builder.Append(atMostOne ? "an optional " : "zero or more "); + } + else if (!atMostOne) + { + builder.Append("one or more "); + } + } + + private static void WriteChoices(FieldChainPattern pattern, StringBuilder builder) + { + bool pluralize = !pattern.AtMostOne; + bool prefix = pattern is { AtLeastOne: true, AtMostOne: true }; + + pattern.Choices.WriteTo(builder, pluralize, prefix); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs new file mode 100644 index 0000000000..0a1fa13d2c --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// The exception that is thrown when the format of a is invalid. +/// +[PublicAPI] +public sealed class PatternFormatException : FormatException +{ + /// + /// Gets the text of the invalid pattern. + /// + public string Pattern { get; } + + /// + /// Gets the zero-based error position in , or at its end. + /// + public int Position { get; } + + public PatternFormatException(string pattern, int position, string message) + : base(message) + { + Pattern = pattern; + Position = position; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs new file mode 100644 index 0000000000..f32ff953f4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs @@ -0,0 +1,63 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Represents the result of matching a dot-separated resource field chain against a pattern. +/// +[PublicAPI] +public sealed class PatternMatchResult +{ + /// + /// Indicates whether the match succeeded. + /// + public bool IsSuccess { get; } + + /// + /// The resolved field chain, when is true. + /// + /// + /// The chain may be empty, if the pattern allows for that. + /// + public IReadOnlyList FieldChain { get; } + + /// + /// Gets the match failure message, when is false. + /// + public string FailureMessage { get; } + + /// + /// Gets the zero-based position in the resource field chain, or at its end, where the match failure occurred. + /// + public int FailurePosition { get; } + + /// + /// Indicates whether the match failed due to an invalid field chain, irrespective of greedy matching. + /// + public bool IsFieldChainError { get; } + + private PatternMatchResult(bool isSuccess, IReadOnlyList fieldChain, string failureMessage, int failurePosition, + bool isFieldChainError) + { + IsSuccess = isSuccess; + FieldChain = fieldChain; + FailureMessage = failureMessage; + FailurePosition = failurePosition; + IsFieldChainError = isFieldChainError; + } + + internal static PatternMatchResult CreateForSuccess(IReadOnlyList fieldChain) + { + ArgumentGuard.NotNull(fieldChain); + + return new PatternMatchResult(true, fieldChain, string.Empty, -1, false); + } + + internal static PatternMatchResult CreateForFailure(MatchError error) + { + ArgumentGuard.NotNull(error); + + return new PatternMatchResult(false, Array.Empty(), error.Message, error.Position, error.IsFieldChainError); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs new file mode 100644 index 0000000000..8172bdaa95 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -0,0 +1,241 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Matches a resource field chain against a pattern. +/// +internal sealed class PatternMatcher +{ + private readonly FieldChainPattern _pattern; + private readonly ILogger _logger; + private readonly bool _allowDerivedTypes; + + public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ILogger logger) + { + ArgumentGuard.NotNull(pattern); + ArgumentGuard.NotNull(logger); + + _pattern = pattern; + _logger = logger; + _allowDerivedTypes = options.HasFlag(FieldChainPatternMatchOptions.AllowDerivedTypes); + } + + public PatternMatchResult Match(string fieldChain, ResourceType resourceType) + { + ArgumentGuard.NotNull(fieldChain); + ArgumentGuard.NotNull(resourceType); + + var startState = MatchState.Create(_pattern, fieldChain, resourceType); + + if (startState.Error != null) + { + return PatternMatchResult.CreateForFailure(startState.Error); + } + + using var traceScope = MatchTraceScope.CreateRoot(startState, _logger); + + MatchState endState = MatchPattern(startState, traceScope); + traceScope.SetResult(endState); + + return endState.Error == null + ? PatternMatchResult.CreateForSuccess(endState.GetAllFieldsMatched()) + : PatternMatchResult.CreateForFailure(endState.Error); + } + + /// + /// Matches the first segment in against . + /// + private MatchState MatchPattern(MatchState state, MatchTraceScope parentTraceScope) + { + AssertIsSuccess(state); + + FieldChainPattern? patternSegment = state.Pattern; + using MatchTraceScope traceScope = parentTraceScope.CreateChild(state); + + if (patternSegment == null) + { + MatchState endState = state.FieldsRemaining == null ? state : state.FailureForTooMuchInput(); + traceScope.LogMatchResult(endState); + traceScope.SetResult(endState); + + return endState; + } + + // Build a stack of successful matches against this pattern segment, incrementally trying to match more fields. + Stack backtrackStack = new(); + + if (!patternSegment.AtLeastOne) + { + // Also include match against empty chain, which always succeeds. + traceScope.LogMatchResult(state); + backtrackStack.Push(state); + } + + MatchState greedyState = state; + + do + { + if (!patternSegment.AtLeastOne && greedyState.FieldsRemaining == null) + { + // Already added above. + continue; + } + + greedyState = MatchField(greedyState); + traceScope.LogMatchResult(greedyState); + + if (greedyState.Error == null) + { + backtrackStack.Push(greedyState); + } + } + while (!patternSegment.AtMostOne && greedyState is { FieldsRemaining: not null, Error: null }); + + // The best error to return is the failure from matching the remaining pattern chain at the most-greedy successful match. + // If matching against the remaining pattern chains doesn't fail, use the most-greedy failure itself. + MatchState bestErrorEndState = greedyState; + + // Evaluate the stacked matches (greedy, so longest first) against the remaining pattern chain. + while (backtrackStack.Count > 0) + { + MatchState backtrackState = backtrackStack.Pop(); + + if (backtrackState != greedyState) + { + // If we're at to most-recent match, and it succeeded, then we're not really backtracking. + traceScope.LogBacktrackTo(backtrackState); + } + + // Match the remaining pattern chain against the remaining field chain. + MatchState endState = MatchPattern(backtrackState.SuccessMoveToNextPattern(), traceScope); + + if (endState.Error == null) + { + traceScope.SetResult(endState); + return endState; + } + + if (bestErrorEndState == greedyState) + { + bestErrorEndState = endState; + } + } + + if (greedyState.Error?.IsFieldChainError == true) + { + // There was an error in the field chain itself, irrespective of backtracking. + // It is therefore more relevant to report over any other error. + bestErrorEndState = greedyState; + } + + traceScope.SetResult(bestErrorEndState); + return bestErrorEndState; + } + + private static void AssertIsSuccess(MatchState state) + { + if (state.Error != null) + { + throw new InvalidOperationException($"Internal error: Expected successful match, but found error: {state.Error}"); + } + } + + /// + /// Matches the first remaining field against the set of choices in the current pattern segment. + /// + private MatchState MatchField(MatchState state) + { + FieldTypes choices = state.Pattern!.Choices; + ResourceFieldAttribute? chosenField = null; + + if (state.FieldsRemaining != null) + { + string publicName = state.FieldsRemaining.Value; + + HashSet fields = LookupFields(state.ResourceType, publicName); + + if (!fields.Any()) + { + return state.FailureForUnknownField(publicName, _allowDerivedTypes); + } + + chosenField = fields.First(); + + fields.RemoveWhere(field => !IsTypeMatch(field, choices)); + + if (fields.Count == 1) + { + return state.SuccessMoveForwardOneField(fields.First()); + } + + if (fields.Count > 1) + { + return state.FailureForMultipleDerivedTypes(publicName); + } + } + + FieldTypes chosenFieldType = GetFieldType(chosenField); + return state.FailureForFieldTypeMismatch(choices, chosenFieldType); + } + + /// + /// Lookup the specified field in the resource graph. + /// + private HashSet LookupFields(ResourceType? resourceType, string publicName) + { + HashSet fields = new(); + + if (resourceType != null) + { + if (_allowDerivedTypes) + { + IReadOnlySet attributes = resourceType.GetAttributesInTypeOrDerived(publicName); + fields.UnionWith(attributes); + + IReadOnlySet relationships = resourceType.GetRelationshipsInTypeOrDerived(publicName); + fields.UnionWith(relationships); + } + else + { + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + fields.Add(attribute); + } + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); + + if (relationship != null) + { + fields.Add(relationship); + } + } + } + + return fields; + } + + private static bool IsTypeMatch(ResourceFieldAttribute field, FieldTypes types) + { + FieldTypes chosenType = GetFieldType(field); + + return (types & chosenType) != FieldTypes.None; + } + + private static FieldTypes GetFieldType(ResourceFieldAttribute? field) + { + return field switch + { + HasManyAttribute => FieldTypes.ToManyRelationship, + HasOneAttribute => FieldTypes.ToOneRelationship, + RelationshipAttribute => FieldTypes.Relationship, + AttrAttribute => FieldTypes.Attribute, + null => FieldTypes.None, + _ => FieldTypes.Field + }; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs new file mode 100644 index 0000000000..a00ec26846 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -0,0 +1,186 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Parses a field chain pattern from text into a chain of segments. +/// +internal sealed class PatternParser +{ + private static readonly Dictionary CharToTokenTable = new() + { + ['?'] = Token.QuestionMark, + ['+'] = Token.Plus, + ['*'] = Token.Asterisk, + ['['] = Token.BracketOpen, + [']'] = Token.BracketClose, + ['M'] = Token.ToManyRelationship, + ['O'] = Token.ToOneRelationship, + ['R'] = Token.Relationship, + ['A'] = Token.Attribute, + ['F'] = Token.Field + }; + + private static readonly Dictionary TokenToFieldTypeTable = new() + { + [Token.ToManyRelationship] = FieldTypes.ToManyRelationship, + [Token.ToOneRelationship] = FieldTypes.ToOneRelationship, + [Token.Relationship] = FieldTypes.Relationship, + [Token.Attribute] = FieldTypes.Attribute, + [Token.Field] = FieldTypes.Field + }; + + private static readonly HashSet QuantifierTokens = new(new[] + { + Token.QuestionMark, + Token.Plus, + Token.Asterisk + }); + + private string _source = null!; + private Queue _tokenQueue = null!; + private int _position; + + public FieldChainPattern Parse(string source) + { + ArgumentGuard.NotNull(source); + + _source = source; + EnqueueTokens(); + + _position = 0; + FieldChainPattern? pattern = TryParsePatternChain(); + + if (pattern == null) + { + throw new PatternFormatException(_source, _position, "Pattern is empty."); + } + + return pattern; + } + + private void EnqueueTokens() + { + _tokenQueue = new Queue(); + _position = 0; + + foreach (char character in _source) + { + if (CharToTokenTable.TryGetValue(character, out Token token)) + { + _tokenQueue.Enqueue(token); + } + else + { + throw new PatternFormatException(_source, _position, $"Unknown token '{character}'."); + } + + _position++; + } + } + + private FieldChainPattern? TryParsePatternChain() + { + if (_tokenQueue.Count == 0) + { + return null; + } + + FieldTypes choices = ParseTypeOrSet(); + (bool atLeastOne, bool atMostOne) = ParseQuantifier(); + FieldChainPattern? next = TryParsePatternChain(); + + return new FieldChainPattern(choices, atLeastOne, atMostOne, next); + } + + private FieldTypes ParseTypeOrSet() + { + bool isChoiceSet = TryEatToken(static token => token == Token.BracketOpen) != null; + FieldTypes choices = EatFieldType(isChoiceSet ? "Field type expected." : "Field type or [ expected."); + + if (isChoiceSet) + { + FieldTypes? extraChoice; + + while ((extraChoice = TryEatFieldType()) != null) + { + choices |= extraChoice.Value; + } + + EatToken(static token => token == Token.BracketClose, "Field type or ] expected."); + } + + return choices; + } + + private (bool atLeastOne, bool atMostOne) ParseQuantifier() + { + Token? quantifier = TryEatToken(static token => QuantifierTokens.Contains(token)); + + return quantifier switch + { + Token.QuestionMark => (false, true), + Token.Plus => (true, false), + Token.Asterisk => (false, false), + _ => (true, true) + }; + } + + private FieldTypes EatFieldType(string errorMessage) + { + FieldTypes? fieldType = TryEatFieldType(); + + if (fieldType != null) + { + return fieldType.Value; + } + + throw new PatternFormatException(_source, _position, errorMessage); + } + + private FieldTypes? TryEatFieldType() + { + Token? token = TryEatToken(static token => TokenToFieldTypeTable.ContainsKey(token)); + + if (token != null) + { + return TokenToFieldTypeTable[token.Value]; + } + + return null; + } + + private void EatToken(Predicate condition, string errorMessage) + { + Token? token = TryEatToken(condition); + + if (token == null) + { + throw new PatternFormatException(_source, _position, errorMessage); + } + } + + private Token? TryEatToken(Predicate condition) + { + if (_tokenQueue.TryPeek(out Token nextToken) && condition(nextToken)) + { + _tokenQueue.Dequeue(); + _position++; + return nextToken; + } + + return null; + } + + private enum Token + { + QuestionMark, + Plus, + Asterisk, + BracketOpen, + BracketClose, + ToManyRelationship, + ToOneRelationship, + Relationship, + Attribute, + Field + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs new file mode 100644 index 0000000000..6e12ad8481 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -0,0 +1,85 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Formats a chain of segments into text. +/// +internal sealed class PatternTextFormatter +{ + private readonly FieldChainPattern _pattern; + + public PatternTextFormatter(FieldChainPattern pattern) + { + ArgumentGuard.NotNull(pattern); + + _pattern = pattern; + } + + public string Format() + { + FieldChainPattern? current = _pattern; + var builder = new StringBuilder(); + + do + { + WriteChoices(current.Choices, builder); + WriteQuantifier(current.AtLeastOne, current.AtMostOne, builder); + + current = current.Next; + } + while (current != null); + + return builder.ToString(); + } + + private static void WriteChoices(FieldTypes types, StringBuilder builder) + { + int startOffset = builder.Length; + + if (types.HasFlag(FieldTypes.ToManyRelationship) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('M'); + } + + if (types.HasFlag(FieldTypes.ToOneRelationship) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('O'); + } + + if (types.HasFlag(FieldTypes.Attribute) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('A'); + } + + if (types.HasFlag(FieldTypes.Relationship) && !types.HasFlag(FieldTypes.Field)) + { + builder.Append('R'); + } + + if (types.HasFlag(FieldTypes.Field)) + { + builder.Append('F'); + } + + int charCount = builder.Length - startOffset; + + if (charCount > 1) + { + builder.Insert(startOffset, '['); + builder.Append(']'); + } + } + + private static void WriteQuantifier(bool atLeastOne, bool atMostOne, StringBuilder builder) + { + if (!atLeastOne) + { + builder.Append(atMostOne ? '?' : '*'); + } + else if (!atMostOne) + { + builder.Append('+'); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs similarity index 78% rename from src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index dace5b8ca4..fe8d35bdca 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -6,47 +6,37 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader { private static readonly LegacyFilterNotationConverter LegacyConverter = new(); private readonly IJsonApiOptions _options; - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly FilterParser _filterParser; + private readonly IQueryStringParameterScopeParser _scopeParser; + private readonly IFilterParser _filterParser; private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); private readonly Dictionary.Builder> _filtersPerScope = new(); - private string? _lastParameterName; - public bool AllowEmptyValue => false; - public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) + public FilterQueryStringParameterReader(IQueryStringParameterScopeParser scopeParser, IFilterParser filterParser, IJsonApiRequest request, + IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) { + ArgumentGuard.NotNull(scopeParser); + ArgumentGuard.NotNull(filterParser); ArgumentGuard.NotNull(options); _options = options; - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceFactory, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field.IsFilterBlocked()) - { - string kind = field is AttrAttribute ? "attribute" : "relationship"; - - throw new InvalidQueryStringParameterException(_lastParameterName!, $"Filtering on the requested {kind} is not allowed.", - $"Filtering on {kind} '{field.PublicName}' is not allowed."); - } + _scopeParser = scopeParser; + _filterParser = filterParser; } /// @@ -69,8 +59,6 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; - foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) { ReadSingleValue(parameterName, value); @@ -97,6 +85,8 @@ private IEnumerable ExtractParameterValue(string? parameterValue) private void ReadSingleValue(string parameterName, string parameterValue) { + bool parameterNameIsValid = false; + try { string name = parameterName; @@ -108,19 +98,25 @@ private void ReadSingleValue(string parameterName, string parameterValue) } ResourceFieldChainExpression? scope = GetScope(name); - FilterExpression filter = GetFilter(value, scope); + parameterNameIsValid = true; + FilterExpression filter = GetFilter(value, scope); StoreFilterInScope(filter, scope); } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); + string specificMessage = _options.EnableLegacyFilterNotation + ? exception.Message + : exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue : parameterName); + + throw new InvalidQueryStringParameterException(parameterName, "The specified filter is invalid.", specificMessage, exception); } } private ResourceFieldChainExpression? GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType, + BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None); if (parameterScope.Scope == null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs similarity index 75% rename from src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs index 7db9a9a7d7..b5fde463ee 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs @@ -5,28 +5,27 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader { - private readonly IJsonApiOptions _options; - private readonly IncludeParser _includeParser; + private readonly IIncludeParser _includeParser; private IncludeExpression? _includeExpression; public bool AllowEmptyValue => true; - public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) + public IncludeQueryStringParameterReader(IIncludeParser includeParser, IJsonApiRequest request, IResourceGraph resourceGraph) : base(request, resourceGraph) { - ArgumentGuard.NotNull(options); + ArgumentGuard.NotNull(includeParser); - _options = options; - _includeParser = new IncludeParser(); + _includeParser = includeParser; } /// @@ -52,13 +51,14 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(parameterValue.ToString()); + throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", specificMessage, exception); } } private IncludeExpression GetInclude(string parameterValue) { - return _includeParser.Parse(parameterValue, RequestResourceType, _options.MaximumIncludeDepth); + return _includeParser.Parse(parameterValue, RequestResourceType); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs similarity index 60% rename from src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs rename to src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs index 259e4c70f1..0480158133 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; [PublicAPI] public sealed class LegacyFilterNotationConverter @@ -10,27 +10,23 @@ public sealed class LegacyFilterNotationConverter private const string ParameterNameSuffix = "]"; private const string OutputParameterName = "filter"; - private const string ExpressionPrefix = "expr:"; - private const string NotEqualsPrefix = "ne:"; - private const string InPrefix = "in:"; - private const string NotInPrefix = "nin:"; - private static readonly Dictionary PrefixConversionTable = new() { - ["eq:"] = Keywords.Equals, - ["lt:"] = Keywords.LessThan, - ["le:"] = Keywords.LessOrEqual, - ["gt:"] = Keywords.GreaterThan, - ["ge:"] = Keywords.GreaterOrEqual, - ["like:"] = Keywords.Contains + [ParameterValuePrefix.Equal] = Keywords.Equals, + [ParameterValuePrefix.LessThan] = Keywords.LessThan, + [ParameterValuePrefix.LessOrEqual] = Keywords.LessOrEqual, + [ParameterValuePrefix.GreaterThan] = Keywords.GreaterThan, + [ParameterValuePrefix.GreaterEqual] = Keywords.GreaterOrEqual, + [ParameterValuePrefix.Like] = Keywords.Contains }; public IEnumerable ExtractConditions(string parameterValue) { ArgumentGuard.NotNullNorEmpty(parameterValue); - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal) || parameterValue.StartsWith(InPrefix, StringComparison.Ordinal) || - parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.Expression, StringComparison.Ordinal) || + parameterValue.StartsWith(ParameterValuePrefix.In, StringComparison.Ordinal) || + parameterValue.StartsWith(ParameterValuePrefix.NotIn, StringComparison.Ordinal)) { yield return parameterValue; } @@ -48,9 +44,9 @@ public IEnumerable ExtractConditions(string parameterValue) ArgumentGuard.NotNullNorEmpty(parameterName); ArgumentGuard.NotNullNorEmpty(parameterValue); - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.Expression, StringComparison.Ordinal)) { - string expression = parameterValue[ExpressionPrefix.Length..]; + string expression = parameterValue[ParameterValuePrefix.Expression.Length..]; return (parameterName, expression); } @@ -68,40 +64,40 @@ public IEnumerable ExtractConditions(string parameterValue) } } - if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.NotEqual, StringComparison.Ordinal)) { - string value = parameterValue[NotEqualsPrefix.Length..]; + string value = parameterValue[ParameterValuePrefix.NotEqual.Length..]; string escapedValue = EscapeQuotes(value); string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; return (OutputParameterName, expression); } - if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.In, StringComparison.Ordinal)) { - string[] valueParts = parameterValue[InPrefix.Length..].Split(","); + string[] valueParts = parameterValue[ParameterValuePrefix.In.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Any}({attributeName},{valueList})"; return (OutputParameterName, expression); } - if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.NotIn, StringComparison.Ordinal)) { - string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); + string[] valueParts = parameterValue[ParameterValuePrefix.NotIn.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; return (OutputParameterName, expression); } - if (parameterValue == "isnull:") + if (parameterValue == ParameterValuePrefix.IsNull) { string expression = $"{Keywords.Equals}({attributeName},null)"; return (OutputParameterName, expression); } - if (parameterValue == "isnotnull:") + if (parameterValue == ParameterValuePrefix.IsNotNull) { string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; return (OutputParameterName, expression); @@ -128,11 +124,27 @@ private static string ExtractAttributeName(string parameterName) } } - throw new QueryParseException("Expected field name between brackets in filter parameter name."); + throw new QueryParseException("Expected field name between brackets in filter parameter name.", -1); } private static string EscapeQuotes(string text) { return text.Replace("'", "''"); } + + private sealed class ParameterValuePrefix + { + public const string Equal = "eq:"; + public const string NotEqual = "ne:"; + public const string LessThan = "lt:"; + public const string LessOrEqual = "le:"; + public const string GreaterThan = "gt:"; + public const string GreaterEqual = "ge:"; + public const string Like = "like:"; + public const string In = "in:"; + public const string NotIn = "nin:"; + public const string IsNull = "isnull:"; + public const string IsNotNull = "isnotnull:"; + public const string Expression = "expr:"; + } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs similarity index 74% rename from src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs index c6ec12afb6..86feed30bf 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -6,11 +6,12 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader { @@ -18,20 +19,22 @@ public class PaginationQueryStringParameterReader : QueryStringParameterReader, private const string PageNumberParameterName = "page[number]"; private readonly IJsonApiOptions _options; - private readonly PaginationParser _paginationParser; + private readonly IPaginationParser _paginationParser; private PaginationQueryStringValueExpression? _pageSizeConstraint; private PaginationQueryStringValueExpression? _pageNumberConstraint; public bool AllowEmptyValue => false; - public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) + public PaginationQueryStringParameterReader(IPaginationParser paginationParser, IJsonApiRequest request, IResourceGraph resourceGraph, + IJsonApiOptions options) : base(request, resourceGraph) { + ArgumentGuard.NotNull(paginationParser); ArgumentGuard.NotNull(options); _options = options; - _paginationParser = new PaginationParser(); + _paginationParser = paginationParser; } /// @@ -51,13 +54,17 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { + bool isParameterNameValid = true; + try { PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue.ToString()); if (constraint.Elements.Any(element => element.Scope == null)) { + isParameterNameValid = false; AssertIsCollectionRequest(); + isParameterNameValid = true; } if (parameterName == PageSizeParameterName) @@ -73,7 +80,8 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(isParameterNameValid ? parameterValue.ToString() : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", specificMessage, exception); } } @@ -84,36 +92,44 @@ private PaginationQueryStringValueExpression GetPageConstraint(string parameterV protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) { - if (_options.MaximumPageSize != null) + foreach (PaginationElementQueryStringValueExpression element in constraint.Elements) { - if (constraint.Elements.Any(element => element.Value > _options.MaximumPageSize.Value)) + if (_options.MaximumPageSize != null) { - throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}."); + if (element.Value > _options.MaximumPageSize.Value) + { + throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}.", element.Position); + } + + if (element.Value == 0) + { + throw new QueryParseException("Page size cannot be unconstrained.", element.Position); + } } - if (constraint.Elements.Any(element => element.Value == 0)) + if (element.Value < 0) { - throw new QueryParseException("Page size cannot be unconstrained."); + throw new QueryParseException("Page size cannot be negative.", element.Position); } } - - if (constraint.Elements.Any(element => element.Value < 0)) - { - throw new QueryParseException("Page size cannot be negative."); - } } - [AssertionMethod] protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression constraint) { - if (_options.MaximumPageNumber != null && constraint.Elements.Any(element => element.Value > _options.MaximumPageNumber.OneBasedValue)) + foreach (PaginationElementQueryStringValueExpression element in constraint.Elements) { - throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}."); - } + if (_options.MaximumPageNumber != null) + { + if (element.Value > _options.MaximumPageNumber.OneBasedValue) + { + throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}.", element.Position); + } + } - if (constraint.Elements.Any(element => element.Value < 1)) - { - throw new QueryParseException("Page number cannot be negative or zero."); + if (element.Value < 1) + { + throw new QueryParseException("Page number cannot be negative or zero.", element.Position); + } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs similarity index 93% rename from src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs index 656cbff0cb..d3f5277881 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs @@ -1,10 +1,10 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; public abstract class QueryStringParameterReader { @@ -47,7 +47,7 @@ protected void AssertIsCollectionRequest() { if (!_isCollectionRequest) { - throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource)."); + throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource).", 0); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs similarity index 93% rename from src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs rename to src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs index b5dda40498..ceb58df45b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs @@ -1,4 +1,3 @@ -using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Diagnostics; @@ -6,11 +5,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; /// -[PublicAPI] -public class QueryStringReader : IQueryStringReader +public sealed class QueryStringReader : IQueryStringReader { private readonly IJsonApiOptions _options; private readonly IRequestQueryStringAccessor _queryStringAccessor; @@ -32,7 +30,7 @@ public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor qu } /// - public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) + public void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs similarity index 93% rename from src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs index 2678627d1c..7b4351aece 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; /// internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs similarity index 98% rename from src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index de02589807..816d9e7f3f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -7,7 +7,7 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs similarity index 67% rename from src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 5e5842c960..9fb98581de 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -5,36 +5,31 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader { - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly SortParser _sortParser; + private readonly IQueryStringParameterScopeParser _scopeParser; + private readonly ISortParser _sortParser; private readonly List _constraints = new(); - private string? _lastParameterName; public bool AllowEmptyValue => false; - public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + public SortQueryStringParameterReader(IQueryStringParameterScopeParser scopeParser, ISortParser sortParser, IJsonApiRequest request, + IResourceGraph resourceGraph) : base(request, resourceGraph) { - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(ValidateSingleField); - } + ArgumentGuard.NotNull(scopeParser); + ArgumentGuard.NotNull(sortParser); - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicName}' is not allowed."); - } + _scopeParser = scopeParser; + _sortParser = sortParser; } /// @@ -57,25 +52,28 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; + bool parameterNameIsValid = false; try { ResourceFieldChainExpression? scope = GetScope(parameterName); - SortExpression sort = GetSort(parameterValue.ToString(), scope); + parameterNameIsValid = true; + SortExpression sort = GetSort(parameterValue.ToString(), scope); var expressionInScope = new ExpressionInScope(scope, sort); _constraints.Add(expressionInScope); } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue.ToString() : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", specificMessage, exception); } } private ResourceFieldChainExpression? GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType, + BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None); if (parameterScope.Scope == null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs similarity index 64% rename from src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index 09c3c0ede8..d79e9c98ed 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -6,43 +6,35 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader { - private readonly SparseFieldTypeParser _sparseFieldTypeParser; - private readonly SparseFieldSetParser _sparseFieldSetParser; + private readonly ISparseFieldTypeParser _scopeParser; + private readonly ISparseFieldSetParser _sparseFieldSetParser; private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = ImmutableDictionary.CreateBuilder(); - private string? _lastParameterName; - /// bool IQueryStringParameterReader.AllowEmptyValue => true; - public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + public SparseFieldSetQueryStringParameterReader(ISparseFieldTypeParser scopeParser, ISparseFieldSetParser sparseFieldSetParser, IJsonApiRequest request, + IResourceGraph resourceGraph) : base(request, resourceGraph) { - _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); - _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field.IsViewBlocked()) - { - string kind = field is AttrAttribute ? "attribute" : "relationship"; + ArgumentGuard.NotNull(scopeParser); + ArgumentGuard.NotNull(sparseFieldSetParser); - throw new InvalidQueryStringParameterException(_lastParameterName!, $"Retrieving the requested {kind} is not allowed.", - $"Retrieving the {kind} '{field.PublicName}' is not allowed."); - } + _scopeParser = scopeParser; + _sparseFieldSetParser = sparseFieldSetParser; } /// @@ -64,24 +56,26 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; + bool parameterNameIsValid = false; try { - ResourceType targetResourceType = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue.ToString(), targetResourceType); + ResourceType resourceType = GetScope(parameterName); + parameterNameIsValid = true; - _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue.ToString(), resourceType); + _sparseFieldTableBuilder[resourceType] = sparseFieldSet; } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue.ToString() : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", specificMessage, exception); } } - private ResourceType GetSparseFieldType(string parameterName) + private ResourceType GetScope(string parameterName) { - return _sparseFieldTypeParser.Parse(parameterName); + return _scopeParser.Parse(parameterName); } private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) @@ -90,7 +84,7 @@ private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, Resour if (sparseFieldSet == null) { - // We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. + // We add ID to an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 66abfafbe0..ca30c222c9 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -136,11 +136,12 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) source = queryableHandler.Apply(source); } - var nameFactory = new LambdaParameterNameFactory(); - - var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _dbContext.Model); +#pragma warning disable CS0618 + IQueryableBuilder builder = _resourceDefinitionAccessor.QueryableBuilder; +#pragma warning restore CS0618 - Expression expression = builder.ApplyQuery(queryLayer); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Queryable), _dbContext.Model, null); + Expression expression = builder.ApplyQuery(queryLayer, context); using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) { @@ -619,7 +620,18 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, [NotNullWhen(true)] object? trackedValueToAssign) { // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }; + if (trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }) + { + IEntityType? leftEntityType = _dbContext.Model.FindEntityType(relationship.LeftType.ClrType); + INavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name); + + if (HasForeignKeyAtLeftSide(relationship, navigation)) + { + return true; + } + } + + return false; } protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index df0061a5aa..d16a6074b2 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources; @@ -20,6 +21,15 @@ public interface IResourceDefinitionAccessor [Obsolete("Use IJsonApiRequest.IsReadOnly.")] bool IsReadOnlyRequest { get; } + /// + /// Gets an instance from the service container. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use injected IQueryableBuilder instead.")] + public IQueryableBuilder QueryableBuilder { get; } + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 9a1c025214..34af2f36fe 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,5 +1,4 @@ using System.Reflection; -using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index fa693d205c..08d6537cbc 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -63,7 +63,7 @@ public virtual IImmutableSet OnApplyIncludes(IImmutabl /// }); /// ]]> /// - protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + protected virtual SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { ArgumentGuard.NotNullNorEmpty(keySelectors); diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 6b7ac6625b..4ebe5cf453 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +26,9 @@ public bool IsReadOnlyRequest } } + /// + public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService(); + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) { ArgumentGuard.NotNull(resourceGraph); diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 27ddc317d8..80aaa2c328 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 8f77a2ff5c..27923f6f62 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -137,7 +137,7 @@ private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, s string url = request.GetEncodedUrl(); EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); - response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); + response.Headers.Append(HeaderNames.ETag, responseETag.ToString()); return RequestContainsMatchingETag(request.Headers, responseETag); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 7550cbf761..4552e46e5b 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -33,6 +33,7 @@ public class LinkBuilder : ILinkBuilder private readonly IHttpContextAccessor _httpContextAccessor; private readonly LinkGenerator _linkGenerator; private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IPaginationParser _paginationParser; private HttpContext HttpContext { @@ -48,13 +49,14 @@ private HttpContext HttpContext } public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, - LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping) + LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser) { ArgumentGuard.NotNull(options); ArgumentGuard.NotNull(request); ArgumentGuard.NotNull(paginationContext); ArgumentGuard.NotNull(linkGenerator); ArgumentGuard.NotNull(controllerResourceMapping); + ArgumentGuard.NotNull(paginationParser); _options = options; _request = request; @@ -62,6 +64,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination _httpContextAccessor = httpContextAccessor; _linkGenerator = linkGenerator; _controllerResourceMapping = controllerResourceMapping; + _paginationParser = paginationParser; } private static string NoAsyncSuffix(string actionName) @@ -153,7 +156,7 @@ private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLin if (topPageSize != null) { - var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value); + var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value, -1); elements = elementInTopScopeIndex != -1 ? elements.SetItem(elementInTopScopeIndex, topPageSizeElement) : elements.Insert(0, topPageSizeElement); } @@ -178,10 +181,9 @@ private IImmutableList ParsePageSiz return ImmutableArray.Empty; } - var parser = new PaginationParser(); - PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, resourceType); + PaginationQueryStringValueExpression pagination = _paginationParser.Parse(pageSizeParameterValue, resourceType); - return paginationExpression.Elements; + return pagination.Elements; } private string GetLinkForPagination(int pageOffset, string? pageSizeValue) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index b1398b7cfb..ceba6d2285 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -5,12 +5,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Response; diff --git a/test/DiscoveryTests/PrivateResource.cs b/test/DiscoveryTests/PrivateResource.cs index ed6dc23204..facbfb6c35 100644 --- a/test/DiscoveryTests/PrivateResource.cs +++ b/test/DiscoveryTests/PrivateResource.cs @@ -1,9 +1,11 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace DiscoveryTests; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] public sealed class PrivateResource : Identifiable { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 925cd2e551..fe94e8f073 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -128,7 +128,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Includes_version_with_ext_on_error_in_operations_endpoint() + public async Task Includes_version_with_ext_on_error_at_operations_endpoint() { // Arrange string musicTrackId = Unknown.StringId.For(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 52ed0ae98b..0abf7385ee 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] public sealed class MusicTrack : Identifiable { - [RegularExpression(@"(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] + [RegularExpression("(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] public override Guid Id { get; set; } [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index efeb369dc4..392a76c08d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -29,7 +29,7 @@ public AtomicQueryStringTests(IntegrationTestContext(); @@ -299,7 +299,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_use_Queryable_handler_on_operations_endpoint() + public async Task Cannot_use_Queryable_handler_at_operations_endpoint() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index 2c1e378e77..6d887a3e9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -117,10 +117,12 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg { if (IsSortOnCarId(sortElement)) { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); + var fieldChain = (ResourceFieldChainExpression)sortElement.Target; + + ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(fieldChain, _regionIdAttribute); elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); + ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(fieldChain, _licensePlateAttribute); elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); } else @@ -134,9 +136,9 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg private static bool IsSortOnCarId(SortElementExpression sortElement) { - if (sortElement.TargetAttribute != null) + if (sortElement.Target is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) { - PropertyInfo property = sortElement.TargetAttribute.Fields[^1].Property; + PropertyInfo property = attribute.Property; if (IsCarId(property)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs index 2170d113ce..5d8793b453 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -4,11 +4,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class ToothbrushesController -{ -} - partial class ToothbrushesController { internal const int EmptyActionResultId = 11111111; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs index 313a8e8849..5d4da838c9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -3,11 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class CiviliansController -{ -} - [ApiController] [DisableRoutingConvention] [Route("world-civilians")] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs index e3fab1ff3c..f99f3aa225 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -8,11 +8,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class TownsController -{ -} - [DisableRoutingConvention] [Route("world-api/civilization/popular/towns")] partial class TownsController diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs index fea280724b..66e8dacb2c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] public sealed class City : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs index bf7b915f03..dc4c27ca54 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -3,11 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class PaintingsController -{ -} - [DisableRoutingConvention] [Route("custom/path/to/paintings-of-the-world")] partial class PaintingsController diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs index 911054f291..663b2a3bd6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -1,9 +1,11 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class BankAccount : ObfuscatedIdentifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs index 323d1bd1e3..99f0154147 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -1,9 +1,11 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class DebitCard : ObfuscatedIdentifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index e9ed2f789c..031ed80fce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -45,6 +45,29 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } + [Fact] + public async Task Cannot_filter_equality_for_invalid_ID() + { + // Arrange + var parameterValue = new MarkedText("equals(id,^'not-a-hex-value')", '^'); + string route = $"/bankAccounts?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"The value 'not-a-hex-value' is not a valid hexadecimal value. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Fact] public async Task Can_filter_any_in_primary_resources() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index 6cd623ac94..ec3d49fe7a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -87,7 +87,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() + public async Task Cannot_clear_required_OneToOne_relationship_at_primary_endpoint() { // Arrange SystemVolume existingVolume = _fakers.SystemVolume.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index ff3360be30..8bfb268daa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -31,7 +31,7 @@ public TopLevelCountTests(IntegrationTestContext, } [Fact] - public async Task Renders_resource_count_for_primary_resources_endpoint_with_filter() + public async Task Renders_resource_count_at_primary_resources_endpoint_with_filter() { // Arrange List tickets = _fakers.SupportTicket.Generate(2); @@ -57,7 +57,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Renders_resource_count_for_secondary_resources_endpoint_with_filter() + public async Task Renders_resource_count_at_secondary_resources_endpoint_with_filter() { // Arrange ProductFamily family = _fakers.ProductFamily.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs index c11db3422e..bdc7068a3d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -3,11 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class WebProductsController -{ -} - [DisableRoutingConvention] [Route("{countryCode}/products")] partial class WebProductsController diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs index b300e0095d..ee31954740 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -3,11 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class WebShopsController -{ -} - [DisableRoutingConvention] [Route("{countryCode}/shops")] partial class WebShopsController diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs index 4cb4581291..b1df54874f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -1,11 +1,13 @@ using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class DivingBoard : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs index 2b715edc7d..ce786ff839 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -1,10 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class SwimmingPool : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs index 95bda04456..9dbd17662e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs @@ -1,10 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class WaterSlide : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index 88e9ecfd82..3c4a6a6bae 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -41,14 +41,13 @@ public async Task Get_skips_middleware_and_formatters() public async Task Post_skips_middleware_and_formatters() { // Arrange - using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") + using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); + + request.Content = new StringContent("Jack") { - Content = new StringContent("Jack") + Headers = { - Headers = - { - ContentType = new MediaTypeHeaderValue("text/plain") - } + ContentType = new MediaTypeHeaderValue("text/plain") } }; @@ -90,14 +89,13 @@ public async Task Post_skips_error_handler() public async Task Put_skips_middleware_and_formatters() { // Arrange - using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") + using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi"); + + request.Content = new StringContent("\"Jane\"") { - Content = new StringContent("\"Jane\"") + Headers = { - Headers = - { - ContentType = new MediaTypeHeaderValue("application/json") - } + ContentType = new MediaTypeHeaderValue("application/json") } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs index 20c95ea769..5b00f1bb0c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class AccountPreferences : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 2f90ed2615..36a93f9ee3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class Appointment : Identifiable { [Attr] @@ -18,4 +19,7 @@ public sealed class Appointment : Identifiable [Attr] public DateTimeOffset EndTime { get; set; } + + [HasMany] + public IList Reminders { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs index 6c8baa0a97..84360c8862 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs @@ -14,6 +14,9 @@ public sealed class Comment : Identifiable [Attr] public DateTime CreatedAt { get; set; } + [Attr] + public int NumStars { get; set; } + [HasOne] public WebAccount? Author { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs new file mode 100644 index 0000000000..bea7ccd2ba --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs @@ -0,0 +1,83 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +/// +/// This expression allows to test if the value of a JSON:API attribute is upper case. It represents the "isUpperCase" filter function, resulting from +/// text such as: +/// +/// isUpperCase(title) +/// +/// , or: +/// +/// isUpperCase(owner.lastName) +/// +/// . +/// +internal sealed class IsUpperCaseExpression : FilterExpression +{ + public const string Keyword = "isUpperCase"; + + /// + /// The string attribute whose value to inspect. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } + + public IsUpperCaseExpression(ResourceFieldChainExpression targetAttribute) + { + ArgumentGuard.NotNull(targetAttribute); + + TargetAttribute = targetAttribute; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (IsUpperCaseExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs new file mode 100644 index 0000000000..aca362aed3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +public sealed class IsUpperCaseFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public IsUpperCaseFilterParseTests() + { + var resourceFactory = new ResourceFactory(new ServiceContainer()); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new IsUpperCaseFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "isUpperCase^", "( expected.")] + [InlineData("filter", "isUpperCase(^", "Field name expected.")] + [InlineData("filter", "isUpperCase(^ ", "Unexpected whitespace.")] + [InlineData("filter", "isUpperCase(^)", "Field name expected.")] + [InlineData("filter", "isUpperCase(^'a')", "Field name expected.")] + [InlineData("filter", "isUpperCase(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "isUpperCase(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "isUpperCase(^null)", "Field name expected.")] + [InlineData("filter", "isUpperCase(title)^)", "End of expression expected.")] + [InlineData("filter", "isUpperCase(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "isUpperCase(title)", null)] + [InlineData("filter", "isUpperCase(owner.userName)", null)] + [InlineData("filter", "has(posts,isUpperCase(author.userName))", null)] + [InlineData("filter", "or(isUpperCase(title),isUpperCase(platformName))", null)] + [InlineData("filter[posts]", "isUpperCase(author.userName)", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs new file mode 100644 index 0000000000..5debe54835 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -0,0 +1,48 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +internal sealed class IsUpperCaseFilterParser : FilterParser +{ + public IsUpperCaseFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: IsUpperCaseExpression.Keyword }) + { + return ParseIsUpperCase(); + } + + return base.ParseFilter(); + } + + private IsUpperCaseExpression ParseIsUpperCase() + { + EatText(IsUpperCaseExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsUpperCaseExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs new file mode 100644 index 0000000000..a61c8ea744 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +public sealed class IsUpperCaseFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public IsUpperCaseFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_casing_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + + blogs[0].Title = blogs[0].Title.ToLowerInvariant(); + blogs[1].Title = blogs[1].Title.ToUpperInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=isUpperCase(title)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(3); + + blog.Posts[0].Caption = blog.Posts[0].Caption.ToUpperInvariant(); + blog.Posts[0].Url = blog.Posts[0].Url.ToUpperInvariant(); + + blog.Posts[1].Caption = blog.Posts[1].Caption.ToUpperInvariant(); + blog.Posts[1].Url = blog.Posts[1].Url.ToLowerInvariant(); + + blog.Posts[2].Caption = blog.Posts[2].Caption.ToLowerInvariant(); + blog.Posts[2].Url = blog.Posts[2].Url.ToLowerInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=and(isUpperCase(caption),not(isUpperCase(url)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_casing_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + blogs[0].Title = blogs[0].Title.ToLowerInvariant(); + blogs[1].Title = blogs[1].Title.ToUpperInvariant(); + + blogs[1].Posts = _fakers.BlogPost.Generate(2); + blogs[1].Posts[0].Caption = blogs[1].Posts[0].Caption.ToLowerInvariant(); + blogs[1].Posts[1].Caption = blogs[1].Posts[1].Caption.ToUpperInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=isUpperCase(title)&include=posts&filter[posts]=isUpperCase(caption)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs new file mode 100644 index 0000000000..691a5aacde --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +internal sealed class IsUpperCaseWhereClauseBuilder : WhereClauseBuilder +{ + private static readonly MethodInfo ToUpperMethod = typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is IsUpperCaseExpression isUpperCaseExpression) + { + return VisitIsUpperCase(isUpperCaseExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitIsUpperCase(IsUpperCaseExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + MethodCallExpression toUpperMethodCall = Expression.Call(propertyAccess, ToUpperMethod); + + return Expression.Equal(propertyAccess, toUpperMethodCall); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs new file mode 100644 index 0000000000..727d3ec808 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs @@ -0,0 +1,87 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +/// +/// This expression allows to determine the string length of a JSON:API attribute. It represents the "length" function, resulting from text such as: +/// +/// length(title) +/// +/// , or: +/// +/// length(owner.lastName) +/// +/// . +/// +internal sealed class LengthExpression : FunctionExpression +{ + public const string Keyword = "length"; + + /// + /// The string attribute whose length to determine. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(int); + + public LengthExpression(ResourceFieldChainExpression targetAttribute) + { + ArgumentGuard.NotNull(targetAttribute); + + TargetAttribute = targetAttribute; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (LengthExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs new file mode 100644 index 0000000000..78352a6ab7 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public LengthFilterParseTests() + { + var resourceFactory = new ResourceFactory(new ServiceContainer()); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new LengthFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(length^", "( expected.")] + [InlineData("filter", "equals(length(^", "Field name expected.")] + [InlineData("filter", "equals(length(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(length(^)", "Field name expected.")] + [InlineData("filter", "equals(length(^'a')", "Field name expected.")] + [InlineData("filter", "equals(length(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(length(^null)", "Field name expected.")] + [InlineData("filter", "equals(length(title)^)", ", expected.")] + [InlineData("filter", "equals(length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "equals(length(title),'1')", null)] + [InlineData("filter", "greaterThan(length(owner.userName),'1')", null)] + [InlineData("filter", "has(posts,lessThan(length(author.userName),'1'))", null)] + [InlineData("filter", "or(equals(length(title),'1'),equals(length(platformName),'1'))", null)] + [InlineData("filter[posts]", "equals(length(author.userName),'1')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs new file mode 100644 index 0000000000..028d40c0ed --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -0,0 +1,58 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthFilterParser : FilterParser +{ + public LengthFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override bool IsFunction(string name) + { + if (name == LengthExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: LengthExpression.Keyword }) + { + return ParseLength(); + } + + return base.ParseFunction(); + } + + private LengthExpression ParseLength() + { + EatText(LengthExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs new file mode 100644 index 0000000000..9954a0925e --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public LengthFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_length_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=greaterThan(length(title),'2')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_length_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(3); + + blog.Posts[0].Caption = "XXX"; + blog.Posts[0].Url = "YYY"; + + blog.Posts[1].Caption = "XXX"; + blog.Posts[1].Url = "Y"; + + blog.Posts[2].Caption = "X"; + blog.Posts[2].Url = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=greaterThan(length(caption),length(url))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_length_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + blogs[1].Posts = _fakers.BlogPost.Generate(2); + blogs[1].Posts[0].Caption = "Y"; + blogs[1].Posts[1].Caption = "YYY"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(length(title),'3')&include=posts&filter[posts]=equals(length(caption),'3')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs new file mode 100644 index 0000000000..4ea6068173 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthOrderClauseBuilder : OrderClauseBuilder +{ + private static readonly MethodInfo LengthPropertyGetter = typeof(string).GetProperty("Length")!.GetGetMethod()!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is LengthExpression lengthExpression) + { + return VisitLength(lengthExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Property(propertyAccess, LengthPropertyGetter); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs new file mode 100644 index 0000000000..c28bec8c8a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthSortParseTests : BaseParseTests +{ + private readonly SortQueryStringParameterReader _reader; + + public LengthSortParseTests() + { + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new LengthSortParser(); + + _reader = new SortQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph); + } + + [Theory] + [InlineData("sort", "length^", "( expected.")] + [InlineData("sort", "length(^", "Field name expected.")] + [InlineData("sort", "length(^ ", "Unexpected whitespace.")] + [InlineData("sort", "length(^)", "Field name expected.")] + [InlineData("sort", "length(^'a')", "Field name expected.")] + [InlineData("sort", "length(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("sort", "length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("sort", "length(^null)", "Field name expected.")] + [InlineData("sort", "length(title)^)", ", expected.")] + [InlineData("sort", "length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("sort", "length(title)", null)] + [InlineData("sort", "length(title),-length(platformName)", null)] + [InlineData("sort", "length(owner.userName)", null)] + [InlineData("sort[posts]", "length(author.userName)", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs new file mode 100644 index 0000000000..6b24d4d4bc --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthSortParser : SortParser +{ + protected override bool IsFunction(string name) + { + if (name == LengthExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction(ResourceType resourceType) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: LengthExpression.Keyword }) + { + return ParseLength(resourceType); + } + + return base.ParseFunction(resourceType); + } + + private LengthExpression ParseLength(ResourceType resourceType) + { + EatText(LengthExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, + FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs new file mode 100644 index 0000000000..46c0a68a07 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthSortTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public LengthSortTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_sort_on_length_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?sort=-length(title)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_sort_on_length_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(3); + + blog.Posts[0].Caption = "XXX"; + blog.Posts[0].Url = "YYY"; + + blog.Posts[1].Caption = "XXX"; + blog.Posts[1].Url = "Y"; + + blog.Posts[2].Caption = "X"; + blog.Posts[2].Url = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?sort=length(caption),length(url)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[2].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); + + responseDocument.Data.ManyValue[2].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[2].Id.Should().Be(blog.Posts[0].StringId); + } + + [Fact] + public async Task Can_sort_on_length_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + blogs[0].Title = "XXX"; + blogs[1].Title = "X"; + + blogs[1].Posts = _fakers.BlogPost.Generate(2); + blogs[1].Posts[0].Caption = "YYY"; + blogs[1].Posts[1].Caption = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?sort=length(title)&include=posts&sort[posts]=length(caption)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Id.Should().Be(blogs[1].Posts[0].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs new file mode 100644 index 0000000000..6d553bdd41 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthWhereClauseBuilder : WhereClauseBuilder +{ + private static readonly MethodInfo LengthPropertyGetter = typeof(string).GetProperty("Length")!.GetGetMethod()!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is LengthExpression lengthExpression) + { + return VisitLength(lengthExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Property(propertyAccess, LengthPropertyGetter); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs new file mode 100644 index 0000000000..7e137ad3d7 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs @@ -0,0 +1,98 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +/// +/// This expression allows to determine the sum of values in the related resources of a to-many relationship. It represents the "sum" function, resulting +/// from text such as: +/// +/// sum(orderLines,quantity) +/// +/// , or: +/// +/// sum(friends,count(children)) +/// +/// . +/// +internal sealed class SumExpression : FunctionExpression +{ + public const string Keyword = "sum"; + + /// + /// The to-many relationship whose related resources are summed over. + /// + public ResourceFieldChainExpression TargetToManyRelationship { get; } + + /// + /// The selector to apply on related resources, which can be a function or a field chain. Chain format: an optional list of to-one relationships, + /// followed by an attribute. The selector must return a numeric type. + /// + public QueryExpression Selector { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(ulong); + + public SumExpression(ResourceFieldChainExpression targetToManyRelationship, QueryExpression selector) + { + ArgumentGuard.NotNull(targetToManyRelationship); + ArgumentGuard.NotNull(selector); + + TargetToManyRelationship = targetToManyRelationship; + Selector = selector; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetToManyRelationship.ToFullString() : TargetToManyRelationship); + builder.Append(','); + builder.Append(toFullString ? Selector.ToFullString() : Selector); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (SumExpression)obj; + + return TargetToManyRelationship.Equals(other.TargetToManyRelationship) && Selector.Equals(other.Selector); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetToManyRelationship, Selector); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs new file mode 100644 index 0000000000..c2cc62e279 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs @@ -0,0 +1,88 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +public sealed class SumFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public SumFilterParseTests() + { + var resourceFactory = new ResourceFactory(new ServiceContainer()); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new SumFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(sum^", "( expected.")] + [InlineData("filter", "equals(sum(^", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(sum(^)", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^'a')", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^null)", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(sum(^title)", + "Field chain on resource type 'blogs' failed to match the pattern: a to-many relationship. " + + "To-many relationship on resource type 'blogs' expected.")] + [InlineData("filter", "equals(sum(posts^))", ", expected.")] + [InlineData("filter", "equals(sum(posts,^))", "Field name expected.")] + [InlineData("filter", "equals(sum(posts,author^))", + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by an attribute. " + + "To-one relationship or attribute on resource type 'webAccounts' expected.")] + [InlineData("filter", "equals(sum(posts,^url))", "Attribute of a numeric type expected.")] + [InlineData("filter", "equals(sum(posts,^has(labels)))", "Function that returns a numeric type expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "has(posts,greaterThan(sum(comments,numStars),'5'))", null)] + [InlineData("filter[posts]", "equals(sum(comments,numStars),'11')", "posts")] + [InlineData("filter[posts]", "equals(sum(labels,count(posts)),'8')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs new file mode 100644 index 0000000000..0668d32644 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -0,0 +1,112 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +internal sealed class SumFilterParser : FilterParser +{ + private static readonly FieldChainPattern SingleToManyRelationshipChain = FieldChainPattern.Parse("M"); + + private static readonly HashSet NumericTypes = new(new[] + { + typeof(sbyte), + typeof(byte), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(decimal) + }); + + public SumFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override bool IsFunction(string name) + { + if (name == SumExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: SumExpression.Keyword }) + { + return ParseSum(); + } + + return base.ParseFunction(); + } + + private SumExpression ParseSum() + { + EatText(SumExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, "To-many relationship expected."); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression selector = ParseSumSelectorInScope(targetToManyRelationshipChain); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new SumExpression(targetToManyRelationshipChain, selector); + } + + private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression targetChain) + { + var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single(); + + using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType); + return ParseSumSelector(); + } + + private QueryExpression ParseSumSelector() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + FunctionExpression function = ParseFunction(); + + if (!IsNumericType(function.ReturnType)) + { + throw new QueryParseException("Function that returns a numeric type expected.", position); + } + + return function; + } + + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, null); + + var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; + + if (!IsNumericType(attrAttribute.Property.PropertyType)) + { + throw new QueryParseException("Attribute of a numeric type expected.", position); + } + + return fieldChain; + } + + private static bool IsNumericType(Type type) + { + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + return NumericTypes.Contains(innerType); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs new file mode 100644 index 0000000000..f558fbb36b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs @@ -0,0 +1,141 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +public sealed class SumFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public SumFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_sum_at_primary_endpoint() + { + // Arrange + List posts = _fakers.BlogPost.Generate(2); + + posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[0].Comments.ElementAt(0).NumStars = 0; + posts[0].Comments.ElementAt(1).NumStars = 1; + + posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[1].Comments.ElementAt(0).NumStars = 2; + posts[1].Comments.ElementAt(1).NumStars = 3; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=greaterThan(sum(comments,numStars),'4')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); + } + + [Fact] + public async Task Can_filter_sum_on_count_at_secondary_endpoint() + { + // Arrange + List posts = _fakers.BlogPost.Generate(2); + + posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[0].Comments.ElementAt(0).NumStars = 1; + posts[0].Comments.ElementAt(1).NumStars = 1; + posts[0].Contributors = _fakers.Woman.Generate(1).OfType().ToHashSet(); + + posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[1].Comments.ElementAt(0).NumStars = 2; + posts[1].Comments.ElementAt(1).NumStars = 2; + posts[1].Contributors = _fakers.Man.Generate(2).OfType().ToHashSet(); + posts[1].Contributors.ElementAt(0).Children = _fakers.Woman.Generate(3).OfType().ToHashSet(); + posts[1].Contributors.ElementAt(1).Children = _fakers.Man.Generate(3).OfType().ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=lessThan(sum(comments,numStars),sum(contributors,count(children)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); + } + + [Fact] + public async Task Can_filter_sum_in_included_resources() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(2); + + blog.Posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blog.Posts[0].Comments.ElementAt(0).NumStars = 1; + blog.Posts[0].Comments.ElementAt(1).NumStars = 1; + + blog.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blog.Posts[1].Comments.ElementAt(0).NumStars = 1; + blog.Posts[1].Comments.ElementAt(1).NumStars = 2; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?include=posts&filter[posts]=equals(sum(comments,numStars),'3')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.StringId); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blog.Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs new file mode 100644 index 0000000000..a732490123 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +internal sealed class SumWhereClauseBuilder : WhereClauseBuilder +{ + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is SumExpression sumExpression) + { + return VisitSum(sumExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitSum(SumExpression expression, QueryClauseBuilderContext context) + { + Expression collectionPropertyAccess = Visit(expression.TargetToManyRelationship, context); + + ResourceType selectorResourceType = ((HasManyAttribute)expression.TargetToManyRelationship.Fields.Single()).RightType; + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(selectorResourceType.ClrType); + + var nestedContext = new QueryClauseBuilderContext(collectionPropertyAccess, selectorResourceType, typeof(Enumerable), context.EntityModel, + context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); + + LambdaExpression lambda = GetSelectorLambda(expression.Selector, nestedContext); + + return SumExtensionMethodCall(lambda, nestedContext); + } + + private LambdaExpression GetSelectorLambda(QueryExpression expression, QueryClauseBuilderContext context) + { + Expression body = Visit(expression, context); + return Expression.Lambda(body, context.LambdaScope.Parameter); + } + + private static Expression SumExtensionMethodCall(LambdaExpression selector, QueryClauseBuilderContext context) + { + return Expression.Call(context.ExtensionType, "Sum", context.LambdaScope.Parameter.Type.AsArray(), context.Source, selector); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs new file mode 100644 index 0000000000..138ccdeafd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs @@ -0,0 +1,30 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class FilterRewritingResourceDefinition : JsonApiResourceDefinition + where TResource : class, IIdentifiable +{ + private readonly FilterTimeOffsetRewriter _rewriter; + + public FilterRewritingResourceDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + _rewriter = new FilterTimeOffsetRewriter(systemClock); + } + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (existingFilter != null) + { + return (FilterExpression)_rewriter.Visit(existingFilter, null)!; + } + + return existingFilter; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs new file mode 100644 index 0000000000..8adc07fdf0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs @@ -0,0 +1,44 @@ +using JsonApiDotNetCore.Queries.Expressions; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +internal sealed class FilterTimeOffsetRewriter : QueryExpressionRewriter +{ + private static readonly Dictionary InverseComparisonOperatorTable = new() + { + [ComparisonOperator.GreaterThan] = ComparisonOperator.LessThan, + [ComparisonOperator.GreaterOrEqual] = ComparisonOperator.LessOrEqual, + [ComparisonOperator.Equals] = ComparisonOperator.Equals, + [ComparisonOperator.LessThan] = ComparisonOperator.GreaterThan, + [ComparisonOperator.LessOrEqual] = ComparisonOperator.GreaterOrEqual + }; + + private readonly ISystemClock _systemClock; + + public FilterTimeOffsetRewriter(ISystemClock systemClock) + { + _systemClock = systemClock; + } + + public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) + { + if (expression.Right is TimeOffsetExpression timeOffset) + { + DateTime currentTime = _systemClock.UtcNow.UtcDateTime; + + var offsetComparison = + new ComparisonExpression(timeOffset.Value < TimeSpan.Zero ? InverseComparisonOperatorTable[expression.Operator] : expression.Operator, + expression.Left, new LiteralConstantExpression(currentTime + timeOffset.Value)); + + ComparisonExpression? timeComparison = expression.Operator is ComparisonOperator.LessThan or ComparisonOperator.LessOrEqual + ? new ComparisonExpression(timeOffset.Value < TimeSpan.Zero ? ComparisonOperator.LessOrEqual : ComparisonOperator.GreaterOrEqual, + expression.Left, new LiteralConstantExpression(currentTime)) + : null; + + return timeComparison == null ? offsetComparison : new LogicalExpression(LogicalOperator.And, offsetComparison, timeComparison); + } + + return base.VisitComparison(expression, argument); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs new file mode 100644 index 0000000000..110e91012c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs @@ -0,0 +1,92 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +/// +/// This expression wraps a time duration. It represents the "timeOffset" function, resulting from text such as: +/// +/// timeOffset('+0:10:00') +/// +/// , or: +/// +/// timeOffset('-0:10:00') +/// +/// . +/// +internal sealed class TimeOffsetExpression : FunctionExpression +{ + public const string Keyword = "timeOffset"; + + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. + private readonly LiteralConstantExpression _timeSpanConstant; + + /// + /// The time offset, which can be negative. + /// + public TimeSpan Value { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(TimeSpan); + + public TimeOffsetExpression(LiteralConstantExpression timeSpanConstant) + { + ArgumentGuard.NotNull(timeSpanConstant); + + if (timeSpanConstant.TypedValue.GetType() != typeof(TimeSpan)) + { + throw new ArgumentException($"Constant must contain a {nameof(TimeSpan)}.", nameof(timeSpanConstant)); + } + + _timeSpanConstant = timeSpanConstant; + + Value = (TimeSpan)timeSpanConstant.TypedValue; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(_timeSpanConstant); + builder.Append(')'); + + return builder.ToString(); + } + + public override string ToFullString() + { + return ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (TimeOffsetExpression)obj; + + return Value == other.Value; + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs new file mode 100644 index 0000000000..0bc330785f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs @@ -0,0 +1,90 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +internal sealed class TimeOffsetFilterParser : FilterParser +{ + public TimeOffsetFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override bool IsFunction(string name) + { + if (name == TimeOffsetExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: TimeOffsetExpression.Keyword }) + { + return ParseTimeOffset(); + } + + return base.ParseFunction(); + } + + private TimeOffsetExpression ParseTimeOffset() + { + EatText(TimeOffsetExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + LiteralConstantExpression constant = ParseTimeSpanConstant(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new TimeOffsetExpression(constant); + } + + private LiteralConstantExpression ParseTimeSpanConstant() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + { + string value = token.Value!; + + if (value.Length > 1 && value[0] is '+' or '-') + { + TimeSpan timeSpan = ConvertStringToTimeSpan(value[1..], position); + TimeSpan timeOffset = value[0] == '-' ? -timeSpan : timeSpan; + + return new LiteralConstantExpression(timeOffset, value); + } + } + + throw new QueryParseException("Time offset between quotes expected.", position); + } + + private static TimeSpan ConvertStringToTimeSpan(string value, int position) + { + try + { + return (TimeSpan)RuntimeTypeConverter.ConvertType(value, typeof(TimeSpan))!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{nameof(TimeSpan)}'.", position, exception); + } + } + + protected override ComparisonExpression ParseComparison(string operatorName) + { + int position = GetNextTokenPositionOrEnd(); + ComparisonExpression comparison = base.ParseComparison(operatorName); + + if (comparison.Left is TimeOffsetExpression) + { + throw new QueryParseException("The 'timeOffset' function can only be used at the right side of comparisons.", position); + } + + return comparison; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs new file mode 100644 index 0000000000..3a171bdae0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using FluentAssertions; +using FluentAssertions.Extensions; +using Humanizer; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +public sealed class TimeOffsetTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public TimeOffsetTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddTransient(); + services.AddSingleton(); + services.AddScoped(typeof(IResourceDefinition<,>), typeof(FilterRewritingResourceDefinition<,>)); + }); + } + + [Theory] + [InlineData("-0:10:00", ComparisonOperator.GreaterThan, "0")] // more than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.GreaterOrEqual, "0,1")] // at least 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.Equals, "1")] // exactly 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessThan, "2,3")] // less than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessOrEqual, "1,2,3")] // at most 10 minutes ago + [InlineData("+0:10:00", ComparisonOperator.GreaterThan, "6")] // more than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.GreaterOrEqual, "5,6")] // at least 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.Equals, "5")] // in exactly 10 minutes + [InlineData("+0:10:00", ComparisonOperator.LessThan, "3,4")] // less than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.LessOrEqual, "3,4,5")] // at most 10 minutes in the future + public async Task Can_filter_comparison_on_relative_time(string filterValue, ComparisonOperator comparisonOperator, string matchingRowsExpected) + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + List reminders = _fakers.Reminder.Generate(7); + reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-15)).DateTime.AsUtc(); + reminders[1].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-10)).DateTime.AsUtc(); + reminders[2].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-5)).DateTime.AsUtc(); + reminders[3].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(0)).DateTime.AsUtc(); + reminders[4].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(5)).DateTime.AsUtc(); + reminders[5].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(10)).DateTime.AsUtc(); + reminders[6].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(15)).DateTime.AsUtc(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Reminders.AddRange(reminders); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/reminders?filter={comparisonOperator.ToString().Camelize()}(remindsAt,timeOffset('{filterValue.Replace("+", "%2B")}'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + int[] matchingRowIndices = matchingRowsExpected.Split(',').Select(int.Parse).ToArray(); + responseDocument.Data.ManyValue.ShouldHaveCount(matchingRowIndices.Length); + + foreach (int rowIndex in matchingRowIndices) + { + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == reminders[rowIndex].StringId); + } + } + + [Fact] + public async Task Cannot_filter_comparison_on_missing_relative_time() + { + // Arrange + var parameterValue = new MarkedText("equals(remindsAt,timeOffset(^", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Time offset between quotes expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_comparison_on_invalid_relative_time() + { + // Arrange + var parameterValue = new MarkedText("equals(remindsAt,timeOffset(^'-*'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Failed to convert '*' of type 'String' to type 'TimeSpan'. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_comparison_on_relative_time_at_left_side() + { + // Arrange + var parameterValue = new MarkedText("^equals(timeOffset('-0:10:00'),remindsAt)", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"The 'timeOffset' function can only be used at the right side of comparisons. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_any_on_relative_time() + { + // Arrange + var parameterValue = new MarkedText("any(remindsAt,^timeOffset('-0:10:00'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Value between quotes expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_relative_time() + { + // Arrange + var parameterValue = new MarkedText("startsWith(^remindsAt,timeOffset('-0:10:00'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_comparison_on_relative_time_in_nested_expression() + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + Calendar calendar = _fakers.Calendar.Generate(); + calendar.Appointments = _fakers.Appointment.Generate(2).ToHashSet(); + + calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.Generate(1); + calendar.Appointments.ElementAt(0).Reminders[0].RemindsAt = clock.UtcNow.DateTime.AsUtc(); + + calendar.Appointments.ElementAt(1).Reminders = _fakers.Reminder.Generate(1); + calendar.Appointments.ElementAt(1).Reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(30)).DateTime.AsUtc(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/calendars/{calendar.StringId}/appointments?filter=has(reminders,equals(remindsAt,timeOffset('%2B0:30:00')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Id.Should().Be(calendar.Appointments.ElementAt(1).StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index b910b7e42e..9bacb8097e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -319,7 +319,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/filterableResources?filter=equals(someInt32,'ABC')"; + var parameterValue = new MarkedText("equals(someInt32,^'ABC')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -332,7 +333,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + error.Detail.Should().Be($"Failed to convert 'ABC' of type 'String' to type 'Int32'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index 1d4e1793d9..1bcf0563cf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -11,6 +11,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering; public sealed class FilterDepthTests : IClassFixture, QueryStringDbContext>> { + private const string CollectionErrorMessage = "This query string parameter can only be used on a collection of resources (not on a single resource)."; + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -53,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_filter_in_single_primary_resource() + public async Task Cannot_filter_in_primary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -77,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^filter"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -110,7 +112,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_filter_in_single_secondary_resource() + public async Task Cannot_filter_in_secondary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -134,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^filter"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -315,7 +317,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_filter_in_scope_of_OneToMany_relationship_on_secondary_endpoint() + public async Task Can_filter_in_scope_of_OneToMany_relationship_at_secondary_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 69dd7ca706..bc3c70c59a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -700,7 +700,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_filter_text_match_on_non_string_value() { // Arrange - const string route = "/filterableResources?filter=contains(someInt32,'123')"; + var parameterValue = new MarkedText("contains(^someInt32,'123')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -713,7 +714,30 @@ public async Task Cannot_filter_text_match_on_non_string_value() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("Attribute of type 'String' expected."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_nested_non_string_value() + { + // Arrange + var parameterValue = new MarkedText("contains(parent.parent.^someInt32,'123')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -833,7 +857,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_filter_on_count() + public async Task Can_filter_equality_on_count_at_left_side() { // Arrange var resource = new FilterableResource @@ -864,11 +888,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId); } + [Fact] + public async Task Can_filter_equality_on_count_at_both_sides() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new() + { + Children = new List + { + new() + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.Add(resource); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/filterableResources?filter=equals(count(children),count(parent.children))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(resource.Children.ElementAt(0).StringId); + } + [Fact] public async Task Cannot_filter_on_count_with_incompatible_value() { // Arrange - const string route = "/filterableResources?filter=equals(count(children),'ABC')"; + var parameterValue = new MarkedText("equals(count(children),^'ABC')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -881,7 +943,7 @@ public async Task Cannot_filter_on_count_with_incompatible_value() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + error.Detail.Should().Be($"Failed to convert 'ABC' of type 'String' to type 'Int32'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -926,4 +988,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(resource1.StringId); } + + [Theory] + [InlineData("equals(and(equals(someString,'ABC'),equals(someInt32,'11')),'true')")] + [InlineData("equals(or(greaterThan(someInt32,'150'),equals(someEnum,'Tuesday')),'true')")] + [InlineData("equals(equals(someString,'ABC'),not(lessThan(someInt32,'10')))")] + public async Task Can_filter_nested_on_comparisons(string filterExpression) + { + // Arrange + var resource1 = new FilterableResource + { + SomeString = "ABC", + SomeInt32 = 11, + SomeEnum = DayOfWeek.Tuesday + }; + + var resource2 = new FilterableResource + { + SomeString = "XYZ", + SomeInt32 = 99, + SomeEnum = DayOfWeek.Saturday + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource1, resource2); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterExpression}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(resource1.StringId); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index 104f5a4f79..42eca44c0b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -28,7 +28,8 @@ public FilterTests(IntegrationTestContext, public async Task Cannot_filter_in_unknown_scope() { // Arrange - const string route = $"/webAccounts?filter[{Unknown.Relationship}]=equals(title,null)"; + var parameterName = new MarkedText($"filter[^{Unknown.Relationship}]", '^'); + string route = $"/webAccounts?{parameterName.Text}=equals(title,null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -41,16 +42,17 @@ public async Task Cannot_filter_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterName}"); error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be($"filter[{Unknown.Relationship}]"); + error.Source.Parameter.Should().Be(parameterName.Text); } [Fact] public async Task Cannot_filter_in_unknown_nested_scope() { // Arrange - const string route = $"/webAccounts?filter[posts.{Unknown.Relationship}]=equals(title,null)"; + var parameterName = new MarkedText($"filter[posts.^{Unknown.Relationship}]", '^'); + string route = $"/webAccounts?{parameterName.Text}=equals(title,null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -63,16 +65,17 @@ public async Task Cannot_filter_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterName}"); error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be($"filter[posts.{Unknown.Relationship}]"); + error.Source.Parameter.Should().Be(parameterName.Text); } [Fact] public async Task Cannot_filter_on_attribute_with_blocked_capability() { // Arrange - const string route = "/webAccounts?filter=equals(dateOfBirth,null)"; + var parameterValue = new MarkedText("equals(^dateOfBirth,null)", '^'); + string route = $"/webAccounts?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -84,8 +87,8 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested attribute is not allowed."); - error.Detail.Should().Be("Filtering on attribute 'dateOfBirth' is not allowed."); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Filtering on attribute 'dateOfBirth' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -94,7 +97,8 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() { // Arrange - const string route = "/calendars?filter=has(appointments)"; + var parameterValue = new MarkedText("has(^appointments)", '^'); + string route = $"/calendars?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -106,8 +110,8 @@ public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested relationship is not allowed."); - error.Detail.Should().Be("Filtering on relationship 'appointments' is not allowed."); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Filtering on relationship 'appointments' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs index 52b0ddbdd9..6a8ddc688f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs @@ -95,6 +95,9 @@ public sealed class FilterableResource : Identifiable [Attr] public DayOfWeek? SomeNullableEnum { get; set; } + [HasOne] + public FilterableResource? Parent { get; set; } + [HasMany] public ICollection Children { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 88360bcfa2..3a5a67097d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -256,7 +256,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_include_ManyToMany_relationship_on_secondary_endpoint() + public async Task Can_include_ManyToMany_relationship_at_secondary_endpoint() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -853,7 +853,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_include_unknown_relationship() { // Arrange - const string route = $"/webAccounts?include={Unknown.Relationship}"; + var parameterValue = new MarkedText($"^{Unknown.Relationship}", '^'); + string route = $"/webAccounts?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -866,7 +867,7 @@ public async Task Cannot_include_unknown_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -875,7 +876,8 @@ public async Task Cannot_include_unknown_relationship() public async Task Cannot_include_unknown_nested_relationship() { // Arrange - const string route = $"/blogs?include=posts.{Unknown.Relationship}"; + var parameterValue = new MarkedText($"posts.^{Unknown.Relationship}", '^'); + string route = $"/blogs?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -888,7 +890,7 @@ public async Task Cannot_include_unknown_nested_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -897,7 +899,8 @@ public async Task Cannot_include_unknown_nested_relationship() public async Task Cannot_include_relationship_when_inclusion_blocked() { // Arrange - const string route = "/blogPosts?include=parent"; + var parameterValue = new MarkedText("^parent", '^'); + string route = $"/blogPosts?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -909,8 +912,8 @@ public async Task Cannot_include_relationship_when_inclusion_blocked() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); - error.Detail.Should().Be("Including the relationship 'parent' on 'blogPosts' is not allowed."); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -919,7 +922,8 @@ public async Task Cannot_include_relationship_when_inclusion_blocked() public async Task Cannot_include_relationship_when_nested_inclusion_blocked() { // Arrange - const string route = "/blogs?include=posts.parent"; + var parameterValue = new MarkedText("posts.^parent", '^'); + string route = $"/blogs?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -931,8 +935,8 @@ public async Task Cannot_include_relationship_when_nested_inclusion_blocked() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); - error.Detail.Should().Be("Including the relationship 'parent' in 'posts.parent' on 'blogPosts' is not allowed."); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -1091,7 +1095,8 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = 1; - const string route = "/blogs/123/owner?include=posts.comments"; + var parameterValue = new MarkedText("^posts.comments", '^'); + string route = $"/blogs/123/owner?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -1104,7 +1109,7 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be("Including 'posts.comments' exceeds the maximum inclusion depth of 1."); + error.Detail.Should().Be($"Including 'posts.comments' exceeds the maximum inclusion depth of 1. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs index acd1fa446c..07804aeac6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class Label : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs index 73557d7fcf..007182781a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class LoginAttempt : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 9174c84058..ad6f8a1609 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -12,6 +12,7 @@ public sealed class PaginationWithTotalCountTests : IClassFixture, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -65,7 +66,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_paginate_in_single_primary_endpoint() + public async Task Cannot_paginate_in_primary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -89,7 +90,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[number]"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -162,7 +163,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_paginate_in_single_secondary_endpoint() + public async Task Cannot_paginate_in_secondary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -186,7 +187,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[size]"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -229,7 +230,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_in_scope_of_OneToMany_relationship_on_secondary_endpoint() + public async Task Can_paginate_in_scope_of_OneToMany_relationship_at_secondary_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); @@ -263,7 +264,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint() + public async Task Can_paginate_OneToMany_relationship_at_relationship_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); @@ -295,7 +296,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint_without_inverse_relationship() + public async Task Can_paginate_OneToMany_relationship_at_relationship_endpoint_without_inverse_relationship() { // Arrange WebAccount? account = _fakers.WebAccount.Generate(); @@ -366,7 +367,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_ManyToMany_relationship_on_relationship_endpoint() + public async Task Can_paginate_ManyToMany_relationship_at_relationship_endpoint() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -451,7 +452,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_paginate_in_unknown_scope() { // Arrange - const string route = $"/webAccounts?page[number]={Unknown.Relationship}:1"; + var parameterValue = new MarkedText($"^{Unknown.Relationship}:1", '^'); + string route = $"/webAccounts?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -464,7 +466,7 @@ public async Task Cannot_paginate_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -473,7 +475,8 @@ public async Task Cannot_paginate_in_unknown_scope() public async Task Cannot_paginate_in_unknown_nested_scope() { // Arrange - const string route = $"/webAccounts?page[size]=posts.{Unknown.Relationship}:1"; + var parameterValue = new MarkedText($"posts.^{Unknown.Relationship}:1", '^'); + string route = $"/webAccounts?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -486,7 +489,7 @@ public async Task Cannot_paginate_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs index 57215e12a1..9af4e1201e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs @@ -170,7 +170,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_on_secondary_endpoint() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_at_secondary_endpoint() { // Arrange WebAccount account = _fakers.WebAccount.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index 6b715f0825..024e923313 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -30,7 +30,8 @@ public RangeValidationTests(IntegrationTestContext(route); @@ -43,7 +44,7 @@ public async Task Cannot_use_negative_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page number cannot be negative or zero."); + error.Detail.Should().Be($"Page number cannot be negative or zero. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -52,7 +53,8 @@ public async Task Cannot_use_negative_page_number() public async Task Cannot_use_zero_page_number() { // Arrange - const string route = "/blogs?page[number]=0"; + var parameterValue = new MarkedText("^0", '^'); + string route = $"/blogs?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -65,7 +67,7 @@ public async Task Cannot_use_zero_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page number cannot be negative or zero."); + error.Detail.Should().Be($"Page number cannot be negative or zero. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -111,7 +113,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_use_negative_page_size() { // Arrange - const string route = "/blogs?page[size]=-1"; + var parameterValue = new MarkedText("^-1", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -124,7 +127,7 @@ public async Task Cannot_use_negative_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page size cannot be negative."); + error.Detail.Should().Be($"Page size cannot be negative. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index 66cb0dca57..5a8d375543 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -59,7 +59,8 @@ public async Task Cannot_use_page_number_over_maximum() { // Arrange const int pageNumber = MaximumPageNumber + 1; - string route = $"/blogs?page[number]={pageNumber}"; + var parameterValue = new MarkedText($"^{pageNumber}", '^'); + string route = $"/blogs?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -72,7 +73,7 @@ public async Task Cannot_use_page_number_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}."); + error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -81,7 +82,8 @@ public async Task Cannot_use_page_number_over_maximum() public async Task Cannot_use_zero_page_size() { // Arrange - const string route = "/blogs?page[size]=0"; + var parameterValue = new MarkedText("^0", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -94,7 +96,7 @@ public async Task Cannot_use_zero_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page size cannot be unconstrained."); + error.Detail.Should().Be($"Page size cannot be unconstrained. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -132,7 +134,8 @@ public async Task Cannot_use_page_size_over_maximum() { // Arrange const int pageSize = MaximumPageSize + 1; - string route = $"/blogs?page[size]={pageSize}"; + var parameterValue = new MarkedText($"^{pageSize}", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -145,7 +148,7 @@ public async Task Cannot_use_page_size_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}."); + error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 473a7428ba..3aa40b2725 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -21,6 +21,7 @@ public sealed class QueryStringDbContext : TestableDbContext public DbSet LoginAttempts => Set(); public DbSet Calendars => Set(); public DbSet Appointments => Set(); + public DbSet Reminders => Set(); public QueryStringDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index ab7fc4b77e..4afd53e405 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -31,7 +31,8 @@ internal sealed class QueryStringFakers : FakerContainer .UseSeed(GetFakerSeed()) .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) .RuleFor(comment => comment.CreatedAt, faker => faker.Date.Past() - .TruncateToWholeMilliseconds())); + .TruncateToWholeMilliseconds()) + .RuleFor(comment => comment.NumStars, faker => faker.Random.Int(0, 10))); private readonly Lazy> _lazyWebAccountFaker = new(() => new Faker() @@ -54,6 +55,20 @@ internal sealed class QueryStringFakers : FakerContainer .UseSeed(GetFakerSeed()) .RuleFor(accountPreferences => accountPreferences.UseDarkTheme, faker => faker.Random.Bool())); + private readonly Lazy> _lazyManFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(man => man.Name, faker => faker.Name.FullName()) + .RuleFor(man => man.HasBeard, faker => faker.Random.Bool()) + .RuleFor(man => man.Age, faker => faker.Random.Int(10, 90))); + + private readonly Lazy> _lazyWomanFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(woman => woman.Name, faker => faker.Name.FullName()) + .RuleFor(woman => woman.MaidenName, faker => faker.Name.LastName()) + .RuleFor(woman => woman.Age, faker => faker.Random.Int(10, 90))); + private readonly Lazy> _lazyCalendarFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) @@ -70,6 +85,12 @@ internal sealed class QueryStringFakers : FakerContainer .TruncateToWholeMilliseconds()) .RuleFor(appointment => appointment.EndTime, (faker, appointment) => appointment.StartTime.AddHours(faker.Random.Double(1, 4)))); + private readonly Lazy> _lazyReminderFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(reminder => reminder.RemindsAt, faker => faker.Date.Future() + .TruncateToWholeMilliseconds())); + public Faker Blog => _lazyBlogFaker.Value; public Faker BlogPost => _lazyBlogPostFaker.Value; public Faker