From bb9f3c3a8d72d6e24da7edad211b442f1ce9e997 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:46:07 +0200 Subject: [PATCH 01/53] Count/Length consistency --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a796be61f0..c56a9dafbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,7 +99,7 @@ jobs: # Get the version prefix/suffix from the git tag. For example: 'v1.0.0-preview1-final' => '1.0.0' and 'preview1-final' $segments = $env:GITHUB_REF_NAME -split "-" $versionPrefix = $segments[0].TrimStart('v') - $versionSuffix = $segments.Count -eq 1 ? '' : $segments[1..$($segments.Length-1)] -join '-' + $versionSuffix = $segments.Length -eq 1 ? '' : $segments[1..$($segments.Length - 1)] -join '-' [xml]$xml = Get-Content Directory.Build.props $configuredVersionPrefix = $xml.Project.PropertyGroup[0].JsonApiDotNetCoreVersionPrefix From 88d7814ae84e02d549c5b7bd2bb1b479ce0ac269 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:46:31 +0200 Subject: [PATCH 02/53] Increment version to 5.4.1 (used for pre-release builds from ci) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 1c96340f4f..15fb0782a1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ 7.0.* 4.7.* 2.14.1 - 5.4.0 + 5.4.1 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 enable From 091fa288a95706d4a042b2bff06b89731e6795a2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 24 Sep 2023 22:51:18 +0200 Subject: [PATCH 03/53] GitHub Actions: Clear version suffix when set in project files Using MSBuild property because the `-version-suffix` switch does not accept null or an empty string --- .github/workflows/build.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c56a9dafbb..4de5a74b39 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,12 +124,7 @@ jobs: - name: Build shell: pwsh run: | - if ($env:PACKAGE_VERSION_SUFFIX) { - dotnet build --no-restore --configuration Release --version-suffix=$env:PACKAGE_VERSION_SUFFIX - } - else { - dotnet build --no-restore --configuration Release - } + dotnet build --no-restore --configuration Release /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX - name: Test run: | dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true" -- RunConfiguration.CollectSourceInformation=true DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true @@ -139,12 +134,7 @@ jobs: - name: Generate packages shell: pwsh run: | - if ($env:PACKAGE_VERSION_SUFFIX) { - dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages --version-suffix=$env:PACKAGE_VERSION_SUFFIX - } - else { - dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages - } + dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX - name: Upload packages to artifacts if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v3 From 883422f334a4dc4e905699173059319974ed1ad3 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 25 Sep 2023 00:09:28 +0200 Subject: [PATCH 04/53] Use centralized versions in all package references; split into breaking and non-breaking (floating) version sets --- Directory.Build.props | 68 +++++++++++-------- benchmarks/Benchmarks.csproj | 4 +- .../DatabasePerTenantExample.csproj | 4 +- .../GettingStarted/GettingStarted.csproj | 2 +- .../JsonApiDotNetCoreExample.csproj | 4 +- .../MultiDbContextExample.csproj | 2 +- .../NoEntityFrameworkExample.csproj | 2 +- .../ReportsExample/ReportsExample.csproj | 4 +- .../JsonApiDotNetCore.Annotations.csproj | 4 +- .../JsonApiDotNetCore.SourceGenerators.csproj | 4 +- .../JsonApiDotNetCore.csproj | 12 ++-- .../JsonApiDotNetCoreTests.csproj | 10 +-- .../MultiDbContextTests.csproj | 2 +- .../NoEntityFrameworkTests.csproj | 2 +- .../SourceGeneratorTests.csproj | 2 +- .../TestBuildingBlocks.csproj | 14 ++-- test/UnitTests/UnitTests.csproj | 2 +- 17 files changed, 78 insertions(+), 64 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 15fb0782a1..75d754d5bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,28 +1,6 @@ - - net6.0 - 6.0.* - 7.0.* - 7.0.* - 4.7.* - 2.14.1 - 5.4.1 - $(MSBuildThisFileDirectory)CodingGuidelines.ruleset - 9999 - enable - enable - false - false - - - - - - - - - - true + + $(NoWarn);AV2210 @@ -31,14 +9,50 @@ true - - $(NoWarn);AV2210 + + true - + + net6.0 + 4.1.0 + 0.4.1 + 6.0.0 + 2.14.1 + + + 6.0.* + 0.13.* + 34.0.* + 3.8.* + 4.7.* 6.0.* + 2.1.* + 7.0.* + 6.12.* 2.3.* + 1.3.* + 2023.2.* + 7.0.* + 1.1.* + 7.0.* 17.7.* + 2.5.* + + + + + + + + + + enable + enable + false + false + $(MSBuildThisFileDirectory)CodingGuidelines.ruleset + 5.4.1 diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 23a6876af9..1e97dd290f 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index a48f472a70..c219dfd0f5 100644 --- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -10,7 +10,7 @@ - - + + diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index ab152b79d5..9e0c80b7f4 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -10,6 +10,6 @@ - + diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index a48f472a70..c219dfd0f5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -10,7 +10,7 @@ - - + + diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index ab152b79d5..9e0c80b7f4 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -10,6 +10,6 @@ - + diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index 9f0037b058..e272b4fcbf 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -10,6 +10,6 @@ - + diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index a48f472a70..c219dfd0f5 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -10,7 +10,7 @@ - - + + diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index 119d295b35..48ca676b4a 100644 --- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -46,7 +46,7 @@ - - + + diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index 6d79d8c893..ca6de1a5b7 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -45,7 +45,7 @@ - - + + diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 6a1b8517e6..4407edaf1f 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -36,11 +36,11 @@ - - - - - - + + + + + + diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index e66fbaeacc..7a3fa8ddf8 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -11,12 +11,12 @@ - + - - - + + + - + diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index 0f5f5f2cff..394d72e2d2 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index f651f73c0e..84a36dcbb1 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index 707de9b8c5..d361de38e5 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -12,7 +12,7 @@ - + diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 386e4e846e..ba9a2f5da3 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -8,15 +8,15 @@ - + - - - + + + - - - + + + diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 85bcc57484..f8dcbce984 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -10,7 +10,7 @@ - + From 2f93f4337e544a4ec12cc689e81fd662f924127f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 25 Sep 2023 00:30:51 +0200 Subject: [PATCH 05/53] Fix build failure on empty version suffix --- Build.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Build.ps1 b/Build.ps1 index 4f0912079d..4854651d67 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,3 +1,5 @@ +$versionSuffix="pre" + function VerifySuccessExitCode { if ($LastExitCode -ne 0) { throw "Command failed with exit code $LastExitCode." @@ -6,11 +8,12 @@ function VerifySuccessExitCode { Write-Host "$(pwsh --version)" Write-Host "Active .NET SDK: $(dotnet --version)" +Write-Host "Using version suffix: $versionSuffix" dotnet tool restore VerifySuccessExitCode -dotnet build --configuration Release --version-suffix="pre" +dotnet build --configuration Release /p:VersionSuffix=$versionSuffix VerifySuccessExitCode dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true @@ -19,5 +22,5 @@ VerifySuccessExitCode dotnet reportgenerator -reports:**\coverage.cobertura.xml -targetdir:artifacts\coverage -filefilters:-*.g.cs VerifySuccessExitCode -dotnet pack --no-build --configuration Release --output artifacts/packages --version-suffix="pre" +dotnet pack --no-build --configuration Release --output artifacts/packages /p:VersionSuffix=$versionSuffix VerifySuccessExitCode From 9f5cd9d28ff570c9eb6c914a28a42eb4e9477d85 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 26 Sep 2023 02:00:27 +0200 Subject: [PATCH 06/53] GitHub Actions build: fix version detection --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4de5a74b39..1dc9384515 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,7 +102,7 @@ jobs: $versionSuffix = $segments.Length -eq 1 ? '' : $segments[1..$($segments.Length - 1)] -join '-' [xml]$xml = Get-Content Directory.Build.props - $configuredVersionPrefix = $xml.Project.PropertyGroup[0].JsonApiDotNetCoreVersionPrefix + $configuredVersionPrefix = $xml.Project.PropertyGroup.JsonApiDotNetCoreVersionPrefix | Select-Object -First 1 if ($configuredVersionPrefix -ne $versionPrefix) { Write-Error "Version prefix from git release tag '$versionPrefix' does not match version prefix '$configuredVersionPrefix' stored in Directory.Build.props." # To recover from this: From 2c6b3159c165e7500ce0cafaf019d03941eedfb7 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 Sep 2023 00:37:06 +0200 Subject: [PATCH 07/53] GitHub Actions: scan for vulnerable dependencies in pull requests (#1345) --- .github/workflows/deps-review.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/deps-review.yml diff --git a/.github/workflows/deps-review.yml b/.github/workflows/deps-review.yml new file mode 100644 index 0000000000..b9945082d5 --- /dev/null +++ b/.github/workflows/deps-review.yml @@ -0,0 +1,14 @@ +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v3 From f5d4b520d65156f150bc9314ac7af8a6bfb4b16e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Sep 2023 08:51:16 +0000 Subject: [PATCH 08/53] Bump jetbrains.resharper.globaltools from 2023.2.1 to 2023.2.2 (#1346) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3919f2223e..369dfb58c2 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2023.2.1", + "version": "2023.2.2", "commands": [ "jb" ] From 93f83c388f2a644d7203401c146e11a79388773a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 29 Sep 2023 00:33:52 +0200 Subject: [PATCH 09/53] Provide additional links when creating new issue --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/ISSUE_TEMPLATE/config.yml | 14 ++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .github/ISSUE_TEMPLATE/question.md | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c59acf46d6..02e871bd7c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,6 @@ --- name: Bug report -about: Create a report to help us improve +about: Create a report to help us improve. title: '' labels: 'bug' assignees: '' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8bf0a9112f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: +- name: Documentation + url: https://www.jsonapi.net/usage/resources/index.html + about: Read our comprehensive documentation. +- name: Sponsor JsonApiDotNetCore + url: https://github.com/sponsors/json-api-dotnet + about: Help the continued development. +- name: Ask on Gitter + url: https://gitter.im/json-api-dotnet-core/Lobby + about: Get in touch with the whole community. +- name: Ask on Stack Overflow + url: https://stackoverflow.com/questions/tagged/json-api + about: The best place for asking general-purpose questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index f629ca472d..019f7a9767 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: Feature request -about: Suggest an idea for this project +about: Suggest an idea for this project. title: '' labels: 'enhancement' assignees: '' diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 51304f7f03..3e3c1ba085 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,6 +1,6 @@ --- name: Question -about: Ask a question +about: Ask a question. title: '' labels: 'question' assignees: '' From 507851e1a7ba069aaf3f99092f1c7f656e7d4e7b Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 29 Sep 2023 18:59:23 +0200 Subject: [PATCH 10/53] Removed incomplete and abandoned related project --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 90bb47b405..fe2658711f 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,7 @@ These are some steps you can take to help you understand what this project is an - [Performance Reports](https://github.com/json-api-dotnet/PerformanceReports) - [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb) -- [JsonApiDotNetCore.Marten](https://github.com/wayne-o/JsonApiDotNetCore.Marten) -- [Todo List App](https://github.com/json-api-dotnet/TodoListExample) +- [Ember.js Todo List App](https://github.com/json-api-dotnet/TodoListExample) ## Examples From a37ca56d94726fab0150f659c678bea85241333a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 1 Oct 2023 22:44:21 +0200 Subject: [PATCH 11/53] Minor refactorings on string interpolation and verbatim strings --- src/JsonApiDotNetCore/Middleware/HeaderConstants.cs | 2 +- .../ModelState/ModelStateValidationTests.cs | 4 ++-- .../NamingConventions/KebabCasingTests.cs | 4 ++-- .../NamingConventions/PascalCasingTests.cs | 2 +- .../Pagination/PaginationWithTotalCountTests.cs | 4 ++-- .../ReadWrite/Creating/CreateResourceTests.cs | 2 +- .../ReadWrite/Fetching/FetchResourceTests.cs | 2 +- .../Queries/QueryExpressionRewriterTests.cs | 12 ++++++------ .../CreateSortExpressionFromLambdaTests.cs | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index 43a5989d59..d290ba80eb 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -8,5 +8,5 @@ namespace JsonApiDotNetCore.Middleware; public static class HeaderConstants { public const string MediaType = "application/vnd.api+json"; - public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\""; + public const string AtomicOperationsMediaType = $"{MediaType}; ext=\"https://jsonapi.org/ext/atomic\""; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 5a0738b0a9..9e104eef01 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -125,7 +125,7 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); + error.Detail.Should().Be(@"The field Name must match the regular expression '^[\w\s]+$'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } @@ -534,7 +534,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); + error.Detail.Should().Be(@"The field Name must match the regular expression '^[\w\s]+$'."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index d70f50de0e..f7d407696e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -74,8 +74,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/public-api/swimming-pools/{pool.StringId}/water-slides" + - "?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; + string route = + $"/public-api/swimming-pools/{pool.StringId}/water-slides?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs index 59d442fcba..9f71d9de16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs @@ -79,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/PublicApi/SwimmingPools/{pool.StringId}/WaterSlides" + "?filter=greaterThan(LengthInMeters,'1')&fields[WaterSlides]=LengthInMeters"; + string route = $"/PublicApi/SwimmingPools/{pool.StringId}/WaterSlides?filter=greaterThan(LengthInMeters,'1')&fields[WaterSlides]=LengthInMeters"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index ad6f8a1609..d2163d95cd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -323,10 +323,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be(basePath + "?page%5Bsize%5D=1"); + responseDocument.Links.First.Should().Be($"{basePath}?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); - responseDocument.Links.Next.Should().Be(basePath + "?page%5Bnumber%5D=3&page%5Bsize%5D=1"); + responseDocument.Links.Next.Should().Be($"{basePath}?page%5Bnumber%5D=3&page%5Bsize%5D=1"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 3354afd6bb..5f1f894a9b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -684,7 +684,7 @@ public async Task Cannot_create_resource_on_unknown_resource_type_in_url() } }; - const string route = "/" + Unknown.ResourceType; + const string route = $"/{Unknown.ResourceType}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index 487c6d72d5..853e09d95c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -62,7 +62,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_get_primary_resources_for_unknown_type() { // Arrange - const string route = "/" + Unknown.ResourceType; + const string route = $"/{Unknown.ResourceType}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs index 3e4a6b2276..3d2d8faa85 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs @@ -53,7 +53,7 @@ public void VisitInclude(string expressionText, string expectedTypes) // Assert List visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToList(); - List expectedTypeNames = expectedTypes.Split(',').Select(type => type + "Expression").ToList(); + List expectedTypeNames = expectedTypes.Split(',').Select(type => $"{type}Expression").ToList(); visitedTypeNames.Should().ContainInOrder(expectedTypeNames); visitedTypeNames.Should().HaveCount(expectedTypeNames.Count); @@ -76,7 +76,7 @@ public void VisitSparseFieldSet(string expressionText, string expectedTypes) // Assert List visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToList(); - List expectedTypeNames = expectedTypes.Split(',').Select(type => type + "Expression").ToList(); + List expectedTypeNames = expectedTypes.Split(',').Select(type => $"{type}Expression").ToList(); visitedTypeNames.Should().ContainInOrder(expectedTypeNames); visitedTypeNames.Should().HaveCount(expectedTypeNames.Count); @@ -136,7 +136,7 @@ public void VisitFilter(string expressionText, string expectedTypes) // Assert List visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToList(); - List expectedTypeNames = expectedTypes.Split(',').Select(type => type + "Expression").ToList(); + List expectedTypeNames = expectedTypes.Split(',').Select(type => $"{type}Expression").ToList(); visitedTypeNames.Should().ContainInOrder(expectedTypeNames); visitedTypeNames.Should().HaveCount(expectedTypeNames.Count); @@ -160,7 +160,7 @@ public void VisitSort(string expressionText, string expectedTypes) // Assert List visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToList(); - List expectedTypeNames = expectedTypes.Split(',').Select(type => type + "Expression").ToList(); + List expectedTypeNames = expectedTypes.Split(',').Select(type => $"{type}Expression").ToList(); visitedTypeNames.Should().ContainInOrder(expectedTypeNames); visitedTypeNames.Should().HaveCount(expectedTypeNames.Count); @@ -183,7 +183,7 @@ public void VisitPagination(string expressionText, string expectedTypes) // Assert List visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToList(); - List expectedTypeNames = expectedTypes.Split(',').Select(type => type + "Expression").ToList(); + List expectedTypeNames = expectedTypes.Split(',').Select(type => $"{type}Expression").ToList(); visitedTypeNames.Should().ContainInOrder(expectedTypeNames); visitedTypeNames.Should().HaveCount(expectedTypeNames.Count); @@ -208,7 +208,7 @@ public void VisitParameterScope(string expressionText, string expectedTypes) // Assert List visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToList(); - List expectedTypeNames = expectedTypes.Split(',').Select(type => type + "Expression").ToList(); + List expectedTypeNames = expectedTypes.Split(',').Select(type => $"{type}Expression").ToList(); visitedTypeNames.Should().ContainInOrder(expectedTypeNames); visitedTypeNames.Should().HaveCount(expectedTypeNames.Count); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs index 3d178073c7..8b919a87cc 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs @@ -317,7 +317,7 @@ public void Cannot_convert_concatenation_operator() // Act Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder { - (file => file.Name + ":" + file.Content, ListSortDirection.Ascending) + (file => $"{file.Name}:{file.Content}", ListSortDirection.Ascending) }); // Assert From 92e6d45a2f60820ffb864b6067cb340805658908 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 1 Oct 2023 22:45:14 +0200 Subject: [PATCH 12/53] Simplify test assertions --- .../SparseFieldSets/SparseFieldSetTests.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 44148b19e7..2dacb19ea7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -71,7 +71,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Caption.Should().Be(post.Caption); postCaptured.Url.Should().BeNull(); } @@ -106,7 +106,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Caption.Should().Be(post.Caption); postCaptured.Url.Should().BeNull(); } @@ -149,7 +149,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Caption.Should().BeNull(); postCaptured.Url.Should().BeNull(); } @@ -193,7 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); + var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).Which; blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().BeNull(); @@ -240,7 +240,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Url.Should().Be(post.Url); postCaptured.Caption.Should().BeNull(); } @@ -299,7 +299,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); @@ -361,7 +361,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var accountCaptured = (WebAccount)store.Resources.Should().ContainSingle(resource => resource is WebAccount).And.Subject.Single(); + var accountCaptured = (WebAccount)store.Resources.Should().ContainSingle(resource => resource is WebAccount).Which; accountCaptured.Id.Should().Be(account.Id); accountCaptured.DisplayName.Should().Be(account.DisplayName); @@ -423,7 +423,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); + var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).Which; blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Owner.ShouldNotBeNull(); blogCaptured.Owner.DisplayName.Should().Be(blog.Owner.DisplayName); @@ -476,7 +476,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Attributes.ShouldContainKey("color").With(value => value.Should().Be(post.Labels.Single().Color)); responseDocument.Included[0].Relationships.Should().BeNull(); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); @@ -532,7 +532,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Owner.Posts[0].Caption)); responseDocument.Included[1].Relationships.Should().BeNull(); - var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); + var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).Which; blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().Be(blog.Title); blogCaptured.PlatformName.Should().BeNull(); @@ -620,7 +620,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); + var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).Which; blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().Be(blog.Title); blogCaptured.PlatformName.Should().BeNull(); @@ -656,7 +656,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); postCaptured.Url.Should().BeNull(); @@ -691,7 +691,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Attributes.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Id.Should().Be(post.Id); postCaptured.Url.Should().BeNull(); } @@ -824,7 +824,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.ShouldContainKey("showAdvertisements").With(value => value.Should().Be(blog.ShowAdvertisements)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); + var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).Which; blogCaptured.ShowAdvertisements.Should().Be(blog.ShowAdvertisements); blogCaptured.IsPublished.Should().Be(blog.IsPublished); blogCaptured.Title.Should().Be(blog.Title); @@ -869,7 +869,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.Links.Related.ShouldNotBeNull(); }); - var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); + var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).Which; postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); postCaptured.Url.Should().Be(post.Url); From 923fbd8a813ac9eb50b4198d1310ab68117a5055 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 1 Oct 2023 22:45:39 +0200 Subject: [PATCH 13/53] More specific test assertion --- .../UnitTests/FieldChains/FieldChainPatternParseTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs index bdb5c6c681..d7e5c22cc1 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs @@ -49,7 +49,7 @@ public void ParseFails(string patternText, string errorMessage) Action action = () => FieldChainPattern.Parse(patternSource.Text); // Assert - PatternFormatException exception = action.Should().Throw().Which; + PatternFormatException exception = action.Should().ThrowExactly().Which; exception.Message.Should().Be(errorMessage); exception.Position.Should().Be(patternSource.Position); exception.Pattern.Should().Be(patternSource.Text); From 2be3358670c712e3861fe36848a89fe3bb1bd617 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 21:55:29 +0000 Subject: [PATCH 14/53] Bump docfx from 2.70.4 to 2.71.0 (#1350) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 369dfb58c2..3bf1e3fa90 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "docfx": { - "version": "2.70.4", + "version": "2.71.0", "commands": [ "docfx" ] From da8562ae998989fe26d66d1843b4cd85bbbf6bc8 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 4 Oct 2023 11:30:22 +0200 Subject: [PATCH 15/53] Add switch for starting pgAdmin in run-docker-postgres --- run-docker-postgres.ps1 | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/run-docker-postgres.ps1 b/run-docker-postgres.ps1 index 153b93a846..0cd42b3893 100644 --- a/run-docker-postgres.ps1 +++ b/run-docker-postgres.ps1 @@ -1,12 +1,18 @@ #Requires -Version 7.0 -# This script starts a docker container with postgres database, used for running tests. +# This script starts a PostgreSQL database in a docker container, which is required for running tests locally. +# When the -UI switch is passed, pgAdmin (a web-based PostgreSQL management tool) is started in a second container, which lets you query the database. +# To connect to pgAdmin, open http://localhost:5050 and login with user "admin@admin.com", password "postgres". Use hostname "db" when registering the server. -docker container stop jsonapi-dotnet-core-testing +param( + [switch] $UI=$False +) -docker run --rm --name jsonapi-dotnet-core-testing ` - -e POSTGRES_DB=JsonApiDotNetCoreExample ` - -e POSTGRES_USER=postgres ` - -e POSTGRES_PASSWORD=postgres ` - -p 5432:5432 ` - postgres:15 +docker container stop jsonapi-postgresql-db +docker container stop jsonapi-postgresql-management + +docker run --pull always --rm --detach --name jsonapi-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest + +if ($UI) { + docker run --pull always --rm --detach --name jsonapi-postgresql-management --link jsonapi-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest +} From 8082765c68519c73763af8cb41b80ae4e66f9ebf Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:15:49 +0200 Subject: [PATCH 16/53] Remove redundant suppressions after update to CSharpGuidelinesAnalyzer v3.8.4 (#1351) --- .../ControllerSourceGenerator.cs | 2 -- .../UnitTests/Links/LinkInclusionTests.cs | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs index 89a511b08e..1b47821d22 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs @@ -6,8 +6,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; -#pragma warning disable RS2008 // Enable analyzer release tracking - namespace JsonApiDotNetCore.SourceGenerators; // To debug in Visual Studio (requires v17.2 or higher): // - Set JsonApiDotNetCore.SourceGenerators as startup project diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 52dc5cb19c..03cb0e2a4c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -403,7 +403,6 @@ public ResourceType GetResourceTypeForController(Type? controllerType) private sealed class FakeLinkGenerator : LinkGenerator { -#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks public override string GetPathByAddress(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary? ambientValues = null, PathString? pathBase = null, FragmentString fragment = new(), LinkOptions? options = null) { @@ -428,6 +427,5 @@ public override string GetUriByAddress(TAddress address, RouteValueDic { throw new NotImplementedException(); } -#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks } } From 164c87ef9f1db0e54788e22e60ae35709b8d5ceb Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 6 Oct 2023 00:00:55 +0200 Subject: [PATCH 17/53] AddJsonApi: Only register IoC services when not already provided This makes app setup easier. Previously, you often had to register overrides *after* calling AddJsonApi (last one wins). With this change, order does not matter anymore. Note that the order of MVC filters still matters, it is unrelated to this change. --- docs/usage/common-pitfalls.md | 8 +- src/Examples/MultiDbContextExample/Program.cs | 6 +- .../NoEntityFrameworkExample/Program.cs | 4 +- .../JsonApiApplicationBuilder.cs | 132 +++++++++--------- .../Configuration/ServiceDiscoveryFacade.cs | 5 +- .../Archiving/ArchiveTests.cs | 2 +- .../Creating/AtomicCreateResourceTests.cs | 2 +- ...reateResourceWithClientGeneratedIdTests.cs | 2 +- .../Links/AtomicAbsoluteLinksTests.cs | 2 +- .../AtomicRelativeLinksWithNamespaceTests.cs | 2 +- .../Meta/AtomicResourceMetaTests.cs | 2 +- .../Meta/AtomicResponseMetaTests.cs | 2 +- .../Mixed/AtomicLoggingTests.cs | 6 +- .../Mixed/AtomicSerializationTests.cs | 2 +- .../AtomicModelStateValidationTests.cs | 2 +- .../QueryStrings/AtomicQueryStringTests.cs | 5 +- ...micSerializationResourceDefinitionTests.cs | 2 +- ...icSparseFieldSetResourceDefinitionTests.cs | 2 +- .../AtomicTransactionConsistencyTests.cs | 2 +- .../Resources/AtomicUpdateResourceTests.cs | 2 +- .../IntegrationTests/Blobs/BlobTests.cs | 2 +- .../CompositeKeys/CompositeKeyTests.cs | 2 +- .../ApiControllerAttributeLogTests.cs | 2 +- .../EagerLoading/EagerLoadingTests.cs | 2 +- .../ExceptionHandlerTests.cs | 9 +- .../ModelState/ModelStateValidationTests.cs | 2 +- .../RequestBody/WorkflowTests.cs | 2 +- .../Links/AbsoluteLinksWithNamespaceTests.cs | 2 +- .../AbsoluteLinksWithoutNamespaceTests.cs | 2 +- .../Links/RelativeLinksWithNamespaceTests.cs | 2 +- .../RelativeLinksWithoutNamespaceTests.cs | 2 +- .../IntegrationTests/Logging/LoggingTests.cs | 2 +- .../Meta/ResourceMetaTests.cs | 3 +- .../Meta/ResponseMetaTests.cs | 2 +- .../Meta/TopLevelCountTests.cs | 2 +- .../FireAndForgetDelivery/FireForgetTests.cs | 2 +- .../TransactionalOutboxPattern/OutboxTests.cs | 2 +- .../MultiTenancy/MultiTenancyTests.cs | 11 +- .../IsUpperCase/IsUpperCaseFilterTests.cs | 2 +- .../StringLength/LengthFilterTests.cs | 2 +- .../StringLength/LengthSortTests.cs | 2 +- .../CustomFunctions/Sum/SumFilterTests.cs | 2 +- .../TimeOffset/TimeOffsetTests.cs | 2 +- .../SparseFieldSets/SparseFieldSetTests.cs | 6 +- ...reateResourceWithClientGeneratedIdTests.cs | 2 +- .../RemoveFromToManyRelationshipTests.cs | 2 +- .../ReplaceToManyRelationshipTests.cs | 2 +- .../Updating/Resources/UpdateResourceTests.cs | 2 +- .../Resources/UpdateToOneRelationshipTests.cs | 2 +- .../ResourceInjectionTests.cs | 2 +- .../Reading/ResourceDefinitionReadTests.cs | 2 +- .../ResourceDefinitionSerializationTests.cs | 3 +- .../ResourceInheritanceReadTests.cs | 2 +- .../ResourceInheritanceWriteTests.cs | 2 +- .../DisableQueryStringTests.cs | 4 +- .../Serialization/SerializationTests.cs | 2 +- .../SoftDeletion/SoftDeletionTests.cs | 8 +- .../DependencyContainerRegistrationTests.cs | 80 +++++++++++ .../IntegrationTestContext.cs | 37 ++--- 59 files changed, 234 insertions(+), 179 deletions(-) diff --git a/docs/usage/common-pitfalls.md b/docs/usage/common-pitfalls.md index 7941face82..f1f3fed3d6 100644 --- a/docs/usage/common-pitfalls.md +++ b/docs/usage/common-pitfalls.md @@ -87,11 +87,13 @@ Neither sounds very compelling. If stored procedures is what you need, you're be Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [`[ApiController]`](https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-7.0#apicontroller-attribute) violates the JSON:API specification. Despite JsonApiDotNetCore trying its best to deal with it, the experience won't be as good as leaving it out. -#### Replace injectable services *after* calling `AddJsonApi()` -Registering your own services in the IoC container afterwards increases the chances that your replacements will take effect. -Also, register with `services.AddResourceDefinition/AddResourceService/AddResourceRepository()` instead of `services.AddScoped()`. +#### Register/override injectable services +Register your JSON:API resource services, resource definitions and repositories with `services.AddResourceService/AddResourceDefinition/AddResourceRepository()` instead of `services.AddScoped()`. When using [Auto-discovery](~/usage/resource-graph.md#auto-discovery), you don't need to register these at all. +> [!NOTE] +> In older versions of JsonApiDotNetCore, registering your own services in the IoC container *afterwards* increased the chances that your replacements would take effect. + #### Never use the Entity Framework Core In-Memory Database Provider When using this provider, many invalid mappings go unnoticed, leading to strange errors or wrong behavior. A real SQL engine fails to create the schema when mappings are invalid. If you're in need of a quick setup, use [SQLite](https://www.sqlite.org/). After adding its [NuGet package](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Sqlite), it's as simple as: diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index a8acd7ae83..2cf567b9b5 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -22,6 +22,9 @@ SetDbContextDebugOptions(options); }); +builder.Services.AddResourceRepository>(); +builder.Services.AddResourceRepository>(); + builder.Services.AddJsonApi(options => { options.Namespace = "api"; @@ -39,9 +42,6 @@ typeof(DbContextB) }); -builder.Services.AddResourceRepository>(); -builder.Services.AddResourceRepository>(); - WebApplication app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 8b299e2c24..8546e939e8 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -5,6 +5,8 @@ // Add services to the container. +builder.Services.AddScoped(); + builder.Services.AddJsonApi(options => { options.Namespace = "api"; @@ -18,8 +20,6 @@ #endif }, discovery => discovery.AddCurrentAssembly()); -builder.Services.AddScoped(); - WebApplication app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index c0b4638e40..17ca6677c3 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -89,7 +89,7 @@ public void ConfigureResourceGraph(ICollection dbContextTypes, Action @@ -109,7 +109,7 @@ public void ConfigureMvc() if (_options.ValidateModelState) { _mvcBuilder.AddDataAnnotations(); - _services.AddSingleton(); + _services.Replace(new ServiceDescriptor(typeof(IModelMetadataProvider), typeof(JsonApiModelMetadataProvider), ServiceLifetime.Singleton)); } } @@ -130,19 +130,19 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) if (dbContextTypes.Any()) { - _services.AddScoped(typeof(DbContextResolver<>)); + _services.TryAddScoped(typeof(DbContextResolver<>)); foreach (Type dbContextType in dbContextTypes) { Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); + _services.TryAddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); } - _services.AddScoped(); + _services.TryAddScoped(); } else { - _services.AddScoped(); + _services.TryAddScoped(); } AddResourceLayer(); @@ -153,46 +153,46 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) AddQueryStringLayer(); AddOperationsLayer(); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddMiddlewareLayer() { - _services.AddSingleton(_options); - _services.AddSingleton(this); - _services.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(sp => sp.GetRequiredService()); - _services.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddSingleton(_options); + _services.TryAddSingleton(this); + _services.TryAddSingleton(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddSingleton(provider => provider.GetRequiredService()); + _services.TryAddSingleton(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddResourceLayer() { RegisterImplementationForInterfaces(ServiceDiscoveryFacade.ResourceDefinitionUnboundInterfaces, typeof(JsonApiResourceDefinition<,>)); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddRepositoryLayer() { RegisterImplementationForInterfaces(ServiceDiscoveryFacade.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(); + _services.TryAddScoped(); _services.TryAddTransient(); _services.TryAddTransient(); @@ -225,12 +225,12 @@ private void AddQueryStringLayer() _services.TryAddTransient(); _services.TryAddTransient(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); RegisterDependentService(); RegisterDependentService(); @@ -246,50 +246,50 @@ private void AddQueryStringLayer() RegisterDependentService(); RegisterDependentService(); - _services.AddScoped(); - _services.AddSingleton(); + _services.TryAddScoped(); + _services.TryAddSingleton(); } private void RegisterDependentService() where TCollectionElement : class where TElementToAdd : TCollectionElement { - _services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + _services.AddScoped(provider => provider.GetRequiredService()); } private void AddSerializationLayer() { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddScoped(); } private void AddOperationsLayer() { - _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); - _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); - _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); - _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); - _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); - _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); + _services.TryAddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); + _services.TryAddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); + _services.TryAddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); + _services.TryAddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); + _services.TryAddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); + + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); } public void Dispose() diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 85f95c232f..d17ddfa1ba 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Configuration; @@ -119,7 +120,7 @@ private void AddDbContextResolvers(Assembly assembly) foreach (Type dbContextType in dbContextTypes) { Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); + _services.TryAddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); } } @@ -163,7 +164,7 @@ private void RegisterImplementations(Assembly assembly, Type interfaceType, Reso if (result != null) { (Type implementationType, Type serviceInterface) = result.Value; - _services.AddScoped(serviceInterface, implementationType); + _services.TryAddScoped(serviceInterface, implementationType); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 2af39b3d26..7c282051f0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -21,7 +21,7 @@ public ArchiveTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 3331b1f1cc..99b3b3bc5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -27,7 +27,7 @@ public AtomicCreateResourceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => + testContext.ConfigureServices(services => { services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 15dbc19b07..5bd4acb39e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -24,7 +24,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index a6e162f72a..fdc7369a71 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -25,7 +25,7 @@ public AtomicAbsoluteLinksTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index db6ee06bbf..9184161c07 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -26,7 +26,7 @@ public AtomicRelativeLinksWithNamespaceTests( testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 2bd9dc8edf..3edb88b14a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -22,7 +22,7 @@ public AtomicResourceMetaTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index ab084a0e90..e4c31db1e6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -21,7 +21,7 @@ public AtomicResponseMetaTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index f871e90238..d59cd3d8b2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -28,13 +28,9 @@ public AtomicLoggingTests(IntegrationTestContext + testContext.ConfigureServices(services => { services.AddSingleton(loggerFactory); - }); - - testContext.ConfigureServicesAfterStartup(services => - { services.AddSingleton(); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 0dcc99fdcd..b59100dbd9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -21,7 +21,7 @@ public AtomicSerializationTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 03a04fb431..4a8b7d9e52 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -18,7 +18,7 @@ public AtomicModelStateValidationTests(IntegrationTestContext + _testContext.ConfigureServices(services => { services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index 392a76c08d..a9d0fbd44b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -21,10 +21,11 @@ public AtomicQueryStringTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { - services.AddSingleton(); services.AddResourceDefinition(); + + services.AddSingleton(); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index 27e44ec234..4e2fa4f937 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -22,7 +22,7 @@ public AtomicSerializationResourceDefinitionTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index f70a289ba1..63fb7e8eb2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -21,7 +21,7 @@ public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index d5e74fa4c3..896813986d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -21,7 +21,7 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceRepository(); services.AddResourceRepository(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 895d1a0df5..0af67d4c20 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -25,7 +25,7 @@ public AtomicUpdateResourceTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs index 4d21e284e8..727245f06e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs @@ -19,7 +19,7 @@ public BlobTests(IntegrationTestContext, BlobDbCo testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 3ff947d7b4..a015ac52fa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -21,7 +21,7 @@ public CompositeKeyTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceRepository>(); services.AddResourceRepository>(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs index 2b4a8c70d6..dce62dec7e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -22,7 +22,7 @@ public ApiControllerAttributeLogTests() options.AddProvider(_loggerFactory); }); - ConfigureServicesBeforeStartup(services => + ConfigureServices(services => { services.AddSingleton(_loggerFactory); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index 5d2ddf0ece..f12123c203 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -21,7 +21,7 @@ public EagerLoadingTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceRepository(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 53a2415627..7bf804a7d0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -30,14 +30,11 @@ public ExceptionHandlerTests(IntegrationTestContext - { - services.AddSingleton(loggerFactory); - }); - - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceService(); + + services.AddSingleton(loggerFactory); services.AddScoped(); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 9e104eef01..55e293aab1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -19,7 +19,7 @@ public ModelStateValidationTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => + testContext.ConfigureServices(services => { // Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation. services.AddDateOnlyTimeOnlyStringConverters(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index 4653118ab6..895a8d2dc8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -17,7 +17,7 @@ public WorkflowTests(IntegrationTestContext, testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 62ba148dc4..e2449d802b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -25,7 +25,7 @@ public AbsoluteLinksWithNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 0d496f18eb..48f49abb99 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -25,7 +25,7 @@ public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index a51f522b82..fb26ca6533 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -25,7 +25,7 @@ public RelativeLinksWithNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index cf614fc8f8..4fc2198bca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -25,7 +25,7 @@ public RelativeLinksWithoutNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index d297467bc4..3b92994de9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -27,7 +27,7 @@ public LoggingTests(IntegrationTestContext, Lo options.SetMinimumLevel(LogLevel.Trace); }); - testContext.ConfigureServicesBeforeStartup(services => + testContext.ConfigureServices(services => { services.AddSingleton(loggerFactory); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs index ba15de73d7..230bdd8b23 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -20,9 +20,10 @@ public ResourceMetaTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); + services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index 5b86a62322..a5ae347886 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -19,7 +19,7 @@ public ResponseMetaTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index 8bfb268daa..955a8514b6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -21,7 +21,7 @@ public TopLevelCountTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs index fe7eabd112..86456d2d43 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs @@ -20,7 +20,7 @@ public FireForgetTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs index 529fcdab6c..2faa69051a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs @@ -23,7 +23,7 @@ public OutboxTests(IntegrationTestContext, Outb testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index b2b100b0bd..e48470c6ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -25,16 +25,13 @@ public MultiTenancyTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(); - services.AddScoped(); - }); - - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceService>(); services.AddResourceService>(); + + services.AddSingleton(); + services.AddScoped(); }); var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs index a61c8ea744..be3240fdef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -20,7 +20,7 @@ public IsUpperCaseFilterTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddTransient(); services.AddTransient(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs index 9954a0925e..5b6874f5b9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs @@ -20,7 +20,7 @@ public LengthFilterTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddTransient(); services.AddTransient(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs index 46c0a68a07..340e571ecf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs @@ -20,7 +20,7 @@ public LengthSortTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddTransient(); services.AddTransient(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs index f558fbb36b..6589120922 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs @@ -21,7 +21,7 @@ public SumFilterTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddTransient(); services.AddTransient(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs index 3a171bdae0..baec74602b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs @@ -25,7 +25,7 @@ public TimeOffsetTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => + testContext.ConfigureServices(services => { services.AddTransient(); services.AddSingleton(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 2dacb19ea7..feba8013b5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -22,13 +22,13 @@ public SparseFieldSetTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { - services.AddSingleton(); - services.AddResourceRepository>(); services.AddResourceRepository>(); services.AddResourceRepository>(); + + services.AddSingleton(); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 58c804a073..3a04f5e937 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -22,7 +22,7 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 82c21e1369..c4928b0924 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -25,7 +25,7 @@ public RemoveFromToManyRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddSingleton, RemoveExtraFromWorkItemDefinition>(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index 5836c2d7de..b3007bb936 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -22,7 +22,7 @@ public ReplaceToManyRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 12cc45a577..6087f09fff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -25,7 +25,7 @@ public UpdateResourceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index f96f4a9efa..636dbe6d3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -22,7 +22,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 19b38cf071..403f86757b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -22,7 +22,7 @@ public ResourceInjectionTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => + testContext.ConfigureServices(services => { services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index ed2b061f80..f1590b52ce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -21,7 +21,7 @@ public ResourceDefinitionReadTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs index a5f5794bd1..950ba84b00 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs @@ -22,13 +22,12 @@ public ResourceDefinitionSerializationTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddSingleton(); services.AddSingleton(); - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs index def4638520..9cea6284b8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs @@ -35,7 +35,7 @@ protected ResourceInheritanceReadTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs index a13629bb76..0fcd65e701 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs @@ -37,7 +37,7 @@ protected ResourceInheritanceWriteTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddSingleton>(); services.AddResourceDefinition>(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index ac2a3aa6a9..22044421e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -19,10 +19,10 @@ public DisableQueryStringTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index da87b6b82d..b7f55bee8e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -23,7 +23,7 @@ public SerializationTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 63ae9689a0..f750826f8c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -25,15 +25,15 @@ public SoftDeletionTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { + services.AddResourceService>(); + services.AddResourceService>(); + services.AddSingleton(new FrozenSystemClock { UtcNow = 1.January(2005).AsUtc() }); - - services.AddResourceService>(); - services.AddResourceService>(); }); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs index 8642461d50..2e9f74add6 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs @@ -1,12 +1,17 @@ using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; using TestBuildingBlocks; using Xunit; @@ -69,6 +74,55 @@ public void Cannot_resolve_registered_services_with_circular_dependency() action.Should().ThrowExactly().WithMessage("Some services are not able to be constructed * A circular dependency was detected *"); } + [Fact] + public void Can_replace_enumerable_service() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddScoped(); + + // Act + services.AddJsonApi(); + + // Assert + ServiceProvider provider = services.BuildServiceProvider(); + + IQueryStringParameterReader[] parameterReaders = provider.GetRequiredService>().ToArray(); + parameterReaders.Should().NotContain(parameterReader => parameterReader is FilterQueryStringParameterReader); + parameterReaders.Should().ContainSingle(parameterReader => parameterReader is CustomFilterQueryStringParameterReader); + + IQueryConstraintProvider[] constraintProviders = provider.GetRequiredService>().ToArray(); + constraintProviders.Should().NotContain(constraintProvider => constraintProvider is FilterQueryStringParameterReader); + constraintProviders.Should().ContainSingle(constraintProvider => constraintProvider is CustomFilterQueryStringParameterReader); + } + + [Fact] + public void Can_add_enumerable_service() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddScoped(); + services.AddScoped(); + + // Act + services.AddJsonApi(); + + // Assert + ServiceProvider provider = services.BuildServiceProvider(); + + IQueryStringParameterReader[] parameterReaders = provider.GetRequiredService>().ToArray(); + parameterReaders.Should().ContainSingle(parameterReader => parameterReader is CustomFilterQueryStringParameterReader); + parameterReaders.Should().ContainSingle(parameterReader => parameterReader is FilterQueryStringParameterReader); + + IQueryConstraintProvider[] constraintProviders = provider.GetRequiredService>().ToArray(); + constraintProviders.Should().ContainSingle(constraintProvider => constraintProvider is CustomFilterQueryStringParameterReader); + constraintProviders.Should().ContainSingle(constraintProvider => constraintProvider is FilterQueryStringParameterReader); + } + private static IHostBuilder CreateValidatingHostBuilder() { IHostBuilder hostBuilder = Host.CreateDefaultBuilder().ConfigureWebHostDefaults(webBuilder => @@ -140,4 +194,30 @@ private sealed class Resource : Identifiable [Attr] public string? Field { get; set; } } + + [UsedImplicitly(ImplicitUseKindFlags.Access)] + private sealed class CustomFilterQueryStringParameterReader : IFilterQueryStringParameterReader + { + public bool AllowEmptyValue => throw new NotImplementedException(); + + public bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + throw new NotImplementedException(); + } + + public bool CanRead(string parameterName) + { + throw new NotImplementedException(); + } + + public void Read(string parameterName, StringValues parameterValue) + { + throw new NotImplementedException(); + } + + public IReadOnlyCollection GetConstraints() + { + throw new NotImplementedException(); + } + } } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index f5d2b072f6..80dd4abfb6 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -33,8 +33,7 @@ public class IntegrationTestContext : IntegrationTest private readonly Lazy> _lazyFactory; private readonly TestControllerProvider _testControllerProvider = new(); private Action? _loggingConfiguration; - private Action? _beforeServicesConfiguration; - private Action? _afterServicesConfiguration; + private Action? _configureServices; protected override JsonSerializerOptions SerializerOptions { @@ -74,9 +73,9 @@ private WebApplicationFactory CreateFactory() factory.ConfigureLogging(_loggingConfiguration); - factory.ConfigureServicesBeforeStartup(services => + factory.ConfigureServices(services => { - _beforeServicesConfiguration?.Invoke(services); + _configureServices?.Invoke(services); services.ReplaceControllers(_testControllerProvider); @@ -87,8 +86,6 @@ private WebApplicationFactory CreateFactory() }); }); - factory.ConfigureServicesAfterStartup(_afterServicesConfiguration); - // We have placed an appsettings.json in the TestBuildingBlock project folder and set the content root to there. Note that controllers // are not discovered in the content root but are registered manually using IntegrationTestContext.UseController. WebApplicationFactory factoryWithConfiguredContentRoot = @@ -114,14 +111,9 @@ public void ConfigureLogging(Action loggingConfiguration) _loggingConfiguration = loggingConfiguration; } - public void ConfigureServicesBeforeStartup(Action servicesConfiguration) - { - _beforeServicesConfiguration = servicesConfiguration; - } - - public void ConfigureServicesAfterStartup(Action servicesConfiguration) + public void ConfigureServices(Action configureServices) { - _afterServicesConfiguration = servicesConfiguration; + _configureServices = configureServices; } public async Task RunOnDatabaseAsync(Func asyncAction) @@ -151,22 +143,16 @@ public override async Task DisposeAsync() private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory { private Action? _loggingConfiguration; - private Action? _beforeServicesConfiguration; - private Action? _afterServicesConfiguration; + private Action? _configureServices; public void ConfigureLogging(Action? loggingConfiguration) { _loggingConfiguration = loggingConfiguration; } - public void ConfigureServicesBeforeStartup(Action? servicesConfiguration) + public void ConfigureServices(Action? configureServices) { - _beforeServicesConfiguration = servicesConfiguration; - } - - public void ConfigureServicesAfterStartup(Action? servicesConfiguration) - { - _afterServicesConfiguration = servicesConfiguration; + _configureServices = configureServices; } protected override IHostBuilder CreateHostBuilder() @@ -189,15 +175,10 @@ protected override IHostBuilder CreateHostBuilder() { webBuilder.ConfigureServices(services => { - _beforeServicesConfiguration?.Invoke(services); + _configureServices?.Invoke(services); }); webBuilder.UseStartup(); - - webBuilder.ConfigureServices(services => - { - _afterServicesConfiguration?.Invoke(services); - }); }) .ConfigureLogging(options => { From f179362897941a0f0a9f0eeec9be08c60b92080c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 6 Oct 2023 00:45:50 +0200 Subject: [PATCH 18/53] Convert legacy unit tests --- .../ServiceCollectionExtensionsTests.cs | 39 +++++-------------- 1 file changed, 10 insertions(+), 29 deletions(-) rename test/{UnitTests/Extensions => JsonApiDotNetCoreTests/UnitTests/Configuration}/ServiceCollectionExtensionsTests.cs (94%) diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs similarity index 94% rename from test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs rename to test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs index 0cb24d8025..d4002a1932 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs @@ -15,12 +15,12 @@ using TestBuildingBlocks; using Xunit; -namespace UnitTests.Extensions; +namespace JsonApiDotNetCoreTests.UnitTests.Configuration; public sealed class ServiceCollectionExtensionsTests { [Fact] - public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() + public void Register_resource_from_DbContext_ignores_deviating_DbContext_property_name() { // Arrange var services = new ServiceCollection(); @@ -39,7 +39,7 @@ public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() } [Fact] - public void AddResourceService_Registers_Service_Interfaces_Of_Int32() + public void Can_register_resource_service_for_Id_type_Int32() { // Arrange var services = new ServiceCollection(); @@ -63,7 +63,7 @@ public void AddResourceService_Registers_Service_Interfaces_Of_Int32() } [Fact] - public void AddResourceService_Registers_Service_Interfaces_Of_Guid() + public void Can_register_resource_service_for_Id_type_Guid() { // Arrange var services = new ServiceCollection(); @@ -87,7 +87,7 @@ public void AddResourceService_Registers_Service_Interfaces_Of_Guid() } [Fact] - public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces() + public void Cannot_register_resource_service_for_type_that_does_not_implement_required_interfaces() { // Arrange var services = new ServiceCollection(); @@ -101,7 +101,7 @@ public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces( } [Fact] - public void AddResourceRepository_Registers_Repository_Interfaces_Of_Int32() + public void Can_register_resource_repository_for_Id_type_Int32() { // Arrange var services = new ServiceCollection(); @@ -118,7 +118,7 @@ public void AddResourceRepository_Registers_Repository_Interfaces_Of_Int32() } [Fact] - public void AddResourceRepository_Registers_Repository_Interfaces_Of_Guid() + public void Can_register_resource_repository_for_Id_type_Guid() { // Arrange var services = new ServiceCollection(); @@ -135,7 +135,7 @@ public void AddResourceRepository_Registers_Repository_Interfaces_Of_Guid() } [Fact] - public void AddResourceDefinition_Registers_Definition_Interface_Of_Int32() + public void Can_register_resource_definition_for_Id_type_Int32() { // Arrange var services = new ServiceCollection(); @@ -150,7 +150,7 @@ public void AddResourceDefinition_Registers_Definition_Interface_Of_Int32() } [Fact] - public void AddResourceDefinition_Registers_Definition_Interface_Of_Guid() + public void Can_register_resource_definition_for_Id_type_Guid() { // Arrange var services = new ServiceCollection(); @@ -164,25 +164,6 @@ public void AddResourceDefinition_Registers_Definition_Interface_Of_Guid() provider.GetRequiredService(typeof(IResourceDefinition)).Should().BeOfType(); } - [Fact] - public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified() - { - // Arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); - - // Act - services.AddJsonApi(); - - // Assert - ServiceProvider provider = services.BuildServiceProvider(); - var resourceGraph = provider.GetRequiredService(); - ResourceType resourceType = resourceGraph.GetResourceType(typeof(ResourceOfInt32)); - - resourceType.PublicName.Should().Be("resourceOfInt32s"); - } - private sealed class ResourceOfInt32 : Identifiable { } @@ -594,7 +575,7 @@ private sealed class TestDbContext : TestableDbContext { public DbSet ResourcesOfInt32 => Set(); public DbSet ResourcesOfGuid => Set(); - public DbSet People => Set(); + public DbSet SetOfPersons => Set(); public TestDbContext(DbContextOptions options) : base(options) From dbfc35d74e2c837995f0a10d04ad49402f7a544c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 6 Oct 2023 01:07:45 +0200 Subject: [PATCH 19/53] Remove logic to obtain PostgreSQL password from environment variable; this is no longer needed using GitHub Actions --- .../DatabasePerTenantExample/Data/AppDbContext.cs | 3 +-- src/Examples/DatabasePerTenantExample/appsettings.json | 6 +++--- src/Examples/JsonApiDotNetCoreExample/Program.cs | 8 +------- src/Examples/JsonApiDotNetCoreExample/appsettings.json | 2 +- .../Transactions/AtomicTransactionConsistencyTests.cs | 4 +--- test/TestBuildingBlocks/IntegrationTestContext.cs | 5 +---- 6 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index cfc82ab27a..40bf7e3f53 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -44,8 +44,7 @@ private string GetConnectionString() throw GetErrorForInvalidTenant(tenantName); } - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return connectionString.Replace("###", postgresPassword); + return connectionString; } private string? GetTenantName() diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index 01687be022..1b5a40da62 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { "ConnectionStrings": { - "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", - "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", - "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=postgres;Include Error Detail=true", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=postgres;Include Error Detail=true", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=postgres;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 2884e7750c..764cc6a6c3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -49,7 +49,7 @@ static void ConfigureServices(WebApplicationBuilder builder) builder.Services.AddDbContext(options => { - string? connectionString = GetConnectionString(builder.Configuration); + string? connectionString = builder.Configuration.GetConnectionString("Default"); options.UseNpgsql(connectionString); SetDbContextDebugOptions(options); @@ -73,12 +73,6 @@ static void ConfigureServices(WebApplicationBuilder builder) } } -static string? GetConnectionString(IConfiguration configuration) -{ - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); -} - [Conditional("DEBUG")] static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 058685ecb1..418fcb7812 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 896813986d..9c054c349b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -27,10 +27,8 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); services.AddResourceRepository(); - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = - $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 80dd4abfb6..a7158f9933 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -64,10 +64,7 @@ protected override HttpClient CreateClient() private WebApplicationFactory CreateFactory() { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - - string dbConnectionString = - $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; + string dbConnectionString = $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; var factory = new IntegrationTestWebApplicationFactory(); From 9aecce69779f94575f8b03851836678dbf7132fb Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 6 Oct 2023 10:35:36 +0200 Subject: [PATCH 20/53] Add whitespace --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 75d754d5bc..73e1b54135 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,9 @@ - + $(NoWarn);AV2210 - + $(NoWarn);1591 true true From 5e569502ff77551080f2f6ad3b72ac9cba8e235b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Oct 2023 19:14:47 +0000 Subject: [PATCH 21/53] Bump dotnet-reportgenerator-globaltool from 5.1.25 to 5.1.26 (#1354) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3bf1e3fa90..7cb5e67971 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,7 +15,7 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.25", + "version": "5.1.26", "commands": [ "reportgenerator" ] From ed1ff0df8bfe57e7289296f637e15af3570f35a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Oct 2023 00:20:55 +0000 Subject: [PATCH 22/53] Bump docfx from 2.71.0 to 2.71.1 (#1359) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 7cb5e67971..e0d4746bdb 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "docfx": { - "version": "2.71.0", + "version": "2.71.1", "commands": [ "docfx" ] From 9830302bbec4592f5b4685daa40b2cb22ce8b95e Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 22 Oct 2023 23:26:27 +0200 Subject: [PATCH 23/53] Code coverage improvements (#1357) * Code coverage: exclude obsolete members, prevent usage of stale coverage results in local build * Add tests to increase coverage * Add justification comment for coverage exclusion --- .github/workflows/build.yml | 5 +- Build.ps1 | 5 +- Directory.Build.props | 1 + JsonApiDotNetCore.sln | 1 + test/NoEntityFrameworkTests/TagTests.cs | 229 ++++++++++++++++++++++++ test/TestBuildingBlocks/AssemblyInfo.cs | 2 +- tests.runsettings | 16 ++ 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 test/NoEntityFrameworkTests/TagTests.cs create mode 100644 tests.runsettings diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dc9384515..750c0476bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -127,10 +127,13 @@ jobs: dotnet build --no-restore --configuration Release /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX - name: Test run: | - dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true" -- RunConfiguration.CollectSourceInformation=true DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true + dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true" - name: Upload coverage to codecov.io if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + verbose: true - name: Generate packages shell: pwsh run: | diff --git a/Build.ps1 b/Build.ps1 index 4854651d67..3abc926e6a 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -10,13 +10,16 @@ Write-Host "$(pwsh --version)" Write-Host "Active .NET SDK: $(dotnet --version)" Write-Host "Using version suffix: $versionSuffix" +Remove-Item -Recurse -Force artifacts -ErrorAction SilentlyContinue +Remove-Item -Recurse -Force * -Include coverage.cobertura.xml + dotnet tool restore VerifySuccessExitCode dotnet build --configuration Release /p:VersionSuffix=$versionSuffix VerifySuccessExitCode -dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true +dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" VerifySuccessExitCode dotnet reportgenerator -reports:**\coverage.cobertura.xml -targetdir:artifacts\coverage -filefilters:-*.g.cs diff --git a/Directory.Build.props b/Directory.Build.props index 73e1b54135..90de08a271 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -53,6 +53,7 @@ false false $(MSBuildThisFileDirectory)CodingGuidelines.ruleset + $(MSBuildThisFileDirectory)tests.runsettings 5.4.1 diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 2f8e9f9127..e10df8567a 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -13,6 +13,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution CodingGuidelines.ruleset = CodingGuidelines.ruleset CSharpGuidelinesAnalyzer.config = CSharpGuidelinesAnalyzer.config Directory.Build.props = Directory.Build.props + tests.runsettings = tests.runsettings EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{026FBC6C-AF76-4568-9B87-EC73457899FD}" diff --git a/test/NoEntityFrameworkTests/TagTests.cs b/test/NoEntityFrameworkTests/TagTests.cs new file mode 100644 index 0000000000..83842b7725 --- /dev/null +++ b/test/NoEntityFrameworkTests/TagTests.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class TagTests : IntegrationTest, IClassFixture> +{ + private readonly NoLoggingWebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TagTests(NoLoggingWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/tags?filter=equals(name,'Personal')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Personal")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/tags?filter=has(todoItems,equals(description,'Check emails'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Business")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/tags?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be("3"); + responseDocument.Data.ManyValue[1].Id.Should().Be("2"); + responseDocument.Data.ManyValue[2].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/tags?sort=-count(todoItems),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + responseDocument.Data.ManyValue[1].Id.Should().Be("2"); + responseDocument.Data.ManyValue[2].Id.Should().Be("3"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/tags?page[size]=1&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Family")); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/tags?fields[tags]=todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.Should().BeNull()); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Relationships.ShouldOnlyContainKeys("todoItems")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/tags?include=todoItems.owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/tags/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/tags/1/todoItems?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Make homework")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Book vacation")); + responseDocument.Data.ManyValue[2].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Cook dinner")); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/tags/2/relationships/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("3"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/TestBuildingBlocks/AssemblyInfo.cs b/test/TestBuildingBlocks/AssemblyInfo.cs index 82d12912a4..2af69d7b71 100644 --- a/test/TestBuildingBlocks/AssemblyInfo.cs +++ b/test/TestBuildingBlocks/AssemblyInfo.cs @@ -1,4 +1,4 @@ using System.Diagnostics.CodeAnalysis; -// https://github.com/coverlet-coverage/coverlet/blob/master/Documentation/MSBuildIntegration.md#excluding-from-coverage +// Justification: This assembly contains building blocks for writing tests. It does not contain code that ships. [assembly: ExcludeFromCodeCoverage] diff --git a/tests.runsettings b/tests.runsettings new file mode 100644 index 0000000000..db83eb983e --- /dev/null +++ b/tests.runsettings @@ -0,0 +1,16 @@ + + + + true + + + + + + ObsoleteAttribute,GeneratedCodeAttribute + true + + + + + From 83f5a6764839709b832c47f1ddfc0629c06a799d Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 27 Oct 2023 03:52:01 +0200 Subject: [PATCH 24/53] Add example that produces SQL without Entity Framework Core (#1361) --- Directory.Build.props | 1 + JsonApiDotNetCore.sln | 30 + JsonApiDotNetCore.sln.DotSettings | 4 + docs/getting-started/faq.md | 14 +- .../AtomicOperations/AmbientTransaction.cs | 61 + .../AmbientTransactionFactory.cs | 78 ++ .../Controllers/OperationsController.cs | 16 + .../DapperExample/DapperExample.csproj | 19 + .../DapperExample/Data/AppDbContext.cs | 81 ++ .../DapperExample/Data/RotatingList.cs | 35 + src/Examples/DapperExample/Data/Seeder.cs | 94 ++ .../DapperExample/DatabaseProvider.cs | 11 + .../Definitions/TodoItemDefinition.cs | 53 + .../FromEntitiesNavigationResolver.cs | 46 + .../DapperExample/Models/AccountRecovery.cs | 19 + .../DapperExample/Models/LoginAccount.cs | 21 + src/Examples/DapperExample/Models/Person.cs | 31 + src/Examples/DapperExample/Models/RgbColor.cs | 55 + src/Examples/DapperExample/Models/Tag.cs | 21 + src/Examples/DapperExample/Models/TodoItem.cs | 36 + .../DapperExample/Models/TodoItemPriority.cs | 11 + src/Examples/DapperExample/Program.cs | 115 ++ .../DapperExample/Properties/AssemblyInfo.cs | 3 + .../Properties/launchSettings.json | 30 + .../CommandDefinitionExtensions.cs | 22 + .../Repositories/DapperFacade.cs | 192 +++ .../Repositories/DapperRepository.cs | 582 ++++++++ .../Repositories/ResourceChangeDetector.cs | 219 +++ .../Repositories/ResultSetMapper.cs | 197 +++ .../Repositories/SqlCaptureStore.cs | 26 + .../DeleteOneToOneStatementBuilder.cs | 37 + .../DeleteResourceStatementBuilder.cs | 37 + .../Builders/InsertStatementBuilder.cs | 55 + .../TranslationToSql/Builders/SelectShape.cs | 22 + .../Builders/SelectStatementBuilder.cs | 786 +++++++++++ .../Builders/SqlQueryBuilder.cs | 505 +++++++ .../Builders/StatementBuilder.cs | 33 + .../UpdateClearOneToOneStatementBuilder.cs | 47 + .../UpdateResourceStatementBuilder.cs | 55 + .../DataModel/BaseDataModelService.cs | 175 +++ .../DataModel/FromEntitiesDataModelService.cs | 145 ++ .../DataModel/IDataModelService.cs | 24 + .../DataModel/RelationshipForeignKey.cs | 69 + .../Generators/ParameterGenerator.cs | 30 + .../Generators/TableAliasGenerator.cs | 12 + .../Generators/UniqueNameGenerator.cs | 26 + .../TranslationToSql/ParameterFormatter.cs | 67 + .../TranslationToSql/SqlCommand.cs | 23 + .../TranslationToSql/SqlTreeNodeVisitor.cs | 151 ++ .../ColumnSelectorUsageCollector.cs | 163 +++ .../Transformations/ColumnVisitMode.cs | 14 + .../Transformations/LogicalCombinator.cs | 58 + .../StaleColumnReferenceRewriter.cs | 307 ++++ .../UnusedSelectorsRewriter.cs | 219 +++ .../TreeNodes/ColumnAssignmentNode.cs | 31 + .../TreeNodes/ColumnInSelectNode.cs | 41 + .../TreeNodes/ColumnInTableNode.cs | 22 + .../TranslationToSql/TreeNodes/ColumnNode.cs | 33 + .../TreeNodes/ColumnSelectorNode.cs | 31 + .../TranslationToSql/TreeNodes/ColumnType.cs | 17 + .../TreeNodes/ComparisonNode.cs | 31 + .../TranslationToSql/TreeNodes/CountNode.cs | 28 + .../TreeNodes/CountSelectorNode.cs | 22 + .../TranslationToSql/TreeNodes/DeleteNode.cs | 28 + .../TranslationToSql/TreeNodes/ExistsNode.cs | 28 + .../TranslationToSql/TreeNodes/FilterNode.cs | 8 + .../TranslationToSql/TreeNodes/FromNode.cs | 19 + .../TranslationToSql/TreeNodes/InNode.cs | 28 + .../TranslationToSql/TreeNodes/InsertNode.cs | 28 + .../TranslationToSql/TreeNodes/JoinNode.cs | 31 + .../TranslationToSql/TreeNodes/JoinType.cs | 7 + .../TranslationToSql/TreeNodes/LikeNode.cs | 31 + .../TranslationToSql/TreeNodes/LogicalNode.cs | 38 + .../TranslationToSql/TreeNodes/NotNode.cs | 25 + .../TreeNodes/NullConstantNode.cs | 18 + .../TreeNodes/OneSelectorNode.cs | 22 + .../TreeNodes/OrderByColumnNode.cs | 29 + .../TreeNodes/OrderByCountNode.cs | 29 + .../TranslationToSql/TreeNodes/OrderByNode.cs | 25 + .../TreeNodes/OrderByTermNode.cs | 14 + .../TreeNodes/ParameterNode.cs | 39 + .../TranslationToSql/TreeNodes/SelectNode.cs | 70 + .../TreeNodes/SelectorNode.cs | 14 + .../TranslationToSql/TreeNodes/SqlTreeNode.cs | 18 + .../TreeNodes/SqlValueNode.cs | 8 + .../TreeNodes/TableAccessorNode.cs | 18 + .../TranslationToSql/TreeNodes/TableNode.cs | 63 + .../TreeNodes/TableSourceNode.cs | 38 + .../TranslationToSql/TreeNodes/UpdateNode.cs | 31 + .../TranslationToSql/TreeNodes/WhereNode.cs | 25 + src/Examples/DapperExample/appsettings.json | 24 + .../JsonApiDotNetCoreExample/Program.cs | 16 +- .../Properties/AssemblyInfo.cs | 1 + .../Resources/Annotations/HasManyAttribute.cs | 22 + .../Properties/AssemblyInfo.cs | 1 + .../Queries/FieldSelectors.cs | 4 +- .../Queries/QueryLayerComposer.cs | 5 + .../QueryLayerIncludeConverter.cs | 21 +- .../QueryableBuilding/QueryableBuilder.cs | 2 +- .../QueryableBuilding/SelectClauseBuilder.cs | 3 +- test/DapperTests/DapperTests.csproj | 17 + .../AtomicOperations/AtomicOperationsTests.cs | 522 +++++++ .../IntegrationTests/DapperTestContext.cs | 163 +++ .../QueryStrings/FilterTests.cs | 1244 +++++++++++++++++ .../QueryStrings/IncludeTests.cs | 234 ++++ .../QueryStrings/PaginationTests.cs | 52 + .../QueryStrings/SortTests.cs | 410 ++++++ .../QueryStrings/SparseFieldSets.cs | 393 ++++++ .../AddToToManyRelationshipTests.cs | 93 ++ .../Relationships/FetchRelationshipTests.cs | 191 +++ .../RemoveFromToManyRelationshipTests.cs | 220 +++ .../ReplaceToManyRelationshipTests.cs | 402 ++++++ .../UpdateToOneRelationshipTests.cs | 1140 +++++++++++++++ .../Resources/CreateResourceTests.cs | 732 ++++++++++ .../Resources/DeleteResourceTests.cs | 123 ++ .../ReadWrite/Resources/FetchResourceTests.cs | 337 +++++ .../Resources/UpdateResourceTests.cs | 399 ++++++ .../Sql/SubQueryInJoinTests.cs | 602 ++++++++ .../IntegrationTests/SqlTextAdapter.cs | 44 + .../IntegrationTests/TestFakers.cs | 61 + .../UnitTests/LogicalCombinatorTests.cs | 49 + .../DapperTests/UnitTests/LogicalNodeTests.cs | 22 + .../UnitTests/ParameterNodeTests.cs | 45 + .../UnitTests/RelationshipForeignKeyTests.cs | 54 + .../UnitTests/SqlTreeNodeVisitorTests.cs | 45 + .../PaginationWithTotalCountTests.cs | 2 +- .../FieldChainPatternInheritanceMatchTests.cs | 2 +- .../FieldChainPatternMatchTests.cs | 2 +- .../ResourceGraph/HasManyAttributeTests.cs | 134 +- .../CollectionExtensions.cs | 12 + .../TestBuildingBlocks/XUnitLoggerProvider.cs | 49 +- 131 files changed, 14001 insertions(+), 64 deletions(-) create mode 100644 src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs create mode 100644 src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs create mode 100644 src/Examples/DapperExample/Controllers/OperationsController.cs create mode 100644 src/Examples/DapperExample/DapperExample.csproj create mode 100644 src/Examples/DapperExample/Data/AppDbContext.cs create mode 100644 src/Examples/DapperExample/Data/RotatingList.cs create mode 100644 src/Examples/DapperExample/Data/Seeder.cs create mode 100644 src/Examples/DapperExample/DatabaseProvider.cs create mode 100644 src/Examples/DapperExample/Definitions/TodoItemDefinition.cs create mode 100644 src/Examples/DapperExample/FromEntitiesNavigationResolver.cs create mode 100644 src/Examples/DapperExample/Models/AccountRecovery.cs create mode 100644 src/Examples/DapperExample/Models/LoginAccount.cs create mode 100644 src/Examples/DapperExample/Models/Person.cs create mode 100644 src/Examples/DapperExample/Models/RgbColor.cs create mode 100644 src/Examples/DapperExample/Models/Tag.cs create mode 100644 src/Examples/DapperExample/Models/TodoItem.cs create mode 100644 src/Examples/DapperExample/Models/TodoItemPriority.cs create mode 100644 src/Examples/DapperExample/Program.cs create mode 100644 src/Examples/DapperExample/Properties/AssemblyInfo.cs create mode 100644 src/Examples/DapperExample/Properties/launchSettings.json create mode 100644 src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs create mode 100644 src/Examples/DapperExample/Repositories/DapperFacade.cs create mode 100644 src/Examples/DapperExample/Repositories/DapperRepository.cs create mode 100644 src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs create mode 100644 src/Examples/DapperExample/Repositories/ResultSetMapper.cs create mode 100644 src/Examples/DapperExample/Repositories/SqlCaptureStore.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/SqlCommand.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs create mode 100644 src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs create mode 100644 src/Examples/DapperExample/appsettings.json rename src/{Examples/NoEntityFrameworkExample => JsonApiDotNetCore/Queries/QueryableBuilding}/QueryLayerIncludeConverter.cs (79%) create mode 100644 test/DapperTests/DapperTests.csproj create mode 100644 test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs create mode 100644 test/DapperTests/IntegrationTests/DapperTestContext.cs create mode 100644 test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs create mode 100644 test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs create mode 100644 test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs create mode 100644 test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs create mode 100644 test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs create mode 100644 test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs create mode 100644 test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs create mode 100644 test/DapperTests/IntegrationTests/SqlTextAdapter.cs create mode 100644 test/DapperTests/IntegrationTests/TestFakers.cs create mode 100644 test/DapperTests/UnitTests/LogicalCombinatorTests.cs create mode 100644 test/DapperTests/UnitTests/LogicalNodeTests.cs create mode 100644 test/DapperTests/UnitTests/ParameterNodeTests.cs create mode 100644 test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs create mode 100644 test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs create mode 100644 test/TestBuildingBlocks/CollectionExtensions.cs diff --git a/Directory.Build.props b/Directory.Build.props index 90de08a271..14cee715e8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -28,6 +28,7 @@ 3.8.* 4.7.* 6.0.* + 2.1.* 2.1.* 7.0.* 6.12.* diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index e10df8567a..4f8bd6f8ef 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -56,6 +56,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnnotationTests", "test\AnnotationTests\AnnotationTests.csproj", "{24B0C12F-38CD-4245-8785-87BEFAD55B00}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperExample", "src\Examples\DapperExample\DapperExample.csproj", "{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperTests", "test\DapperTests\DapperTests.csproj", "{80E322F5-5F5D-4670-A30F-02D33C2C7900}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -282,6 +286,30 @@ Global {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x64.Build.0 = Release|Any CPU {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.ActiveCfg = Release|Any CPU {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -305,6 +333,8 @@ Global {83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {24B0C12F-38CD-4245-8785-87BEFAD55B00} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {80E322F5-5F5D-4670-A30F-02D33C2C7900} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 2602272e97..bf7a5182f0 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -659,8 +659,12 @@ $left$ = $right$; True True True + True True + True True + True + True True True True diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 574ebaf92c..57f1258c24 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -145,12 +145,18 @@ Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonA You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options or analyze query strings. And most resource definition callbacks are handled. -That's because the built-in resource service translates all JSON:API aspects of the request into a database-agnostic data structure called `QueryLayer`. +That's because the built-in resource service translates all JSON:API query aspects of the request into a database-agnostic data structure called `QueryLayer`. Now the hard part for you becomes reading that data structure and producing data access calls from that. -If your data store provides a LINQ provider, you may reuse most of [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs), +If your data store provides a LINQ provider, you can probably reuse [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs), which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/). -Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. There's an example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs). -We use a similar approach for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). +Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll need to +[prevent that from happening](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs). + +The example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs) compiles and executes +the LINQ query against an in-memory list of resources. +For [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/master/src/JsonApiDotNetCore.MongoDb/Repositories/MongoRepository.cs), we use the MongoDB LINQ provider. +If there's no LINQ provider available, the example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/DapperExample/Repositories/DapperRepository.cs) may be of help, +which produces SQL and uses [Dapper](https://github.com/DapperLib/Dapper) for data access. > [!TIP] > [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees! diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs new file mode 100644 index 0000000000..c442861bc4 --- /dev/null +++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs @@ -0,0 +1,61 @@ +using System.Data.Common; +using JsonApiDotNetCore; +using JsonApiDotNetCore.AtomicOperations; + +namespace DapperExample.AtomicOperations; + +/// +/// Represents an ADO.NET transaction in a JSON:API atomic:operations request. +/// +internal sealed class AmbientTransaction : IOperationsTransaction +{ + private readonly AmbientTransactionFactory _owner; + + public DbTransaction Current { get; } + + /// + public string TransactionId { get; } + + public AmbientTransaction(AmbientTransactionFactory owner, DbTransaction current, Guid transactionId) + { + ArgumentGuard.NotNull(owner); + ArgumentGuard.NotNull(current); + + _owner = owner; + Current = current; + TransactionId = transactionId.ToString(); + } + + /// + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return Current.CommitAsync(cancellationToken); + } + + /// + public async ValueTask DisposeAsync() + { + DbConnection? connection = Current.Connection; + + await Current.DisposeAsync(); + + if (connection != null) + { + await connection.DisposeAsync(); + } + + _owner.Detach(this); + } +} diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs new file mode 100644 index 0000000000..d10959b79a --- /dev/null +++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs @@ -0,0 +1,78 @@ +using System.Data.Common; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; + +namespace DapperExample.AtomicOperations; + +/// +/// Provides transaction support for JSON:API atomic:operation requests using ADO.NET. +/// +public sealed class AmbientTransactionFactory : IOperationsTransactionFactory +{ + private readonly IJsonApiOptions _options; + private readonly IDataModelService _dataModelService; + + internal AmbientTransaction? AmbientTransaction { get; private set; } + + public AmbientTransactionFactory(IJsonApiOptions options, IDataModelService dataModelService) + { + ArgumentGuard.NotNull(options); + ArgumentGuard.NotNull(dataModelService); + + _options = options; + _dataModelService = dataModelService; + } + + internal async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + var instance = (IOperationsTransactionFactory)this; + + IOperationsTransaction transaction = await instance.BeginTransactionAsync(cancellationToken); + return (AmbientTransaction)transaction; + } + + async Task IOperationsTransactionFactory.BeginTransactionAsync(CancellationToken cancellationToken) + { + if (AmbientTransaction != null) + { + throw new InvalidOperationException("Cannot start transaction because another transaction is already active."); + } + + DbConnection dbConnection = _dataModelService.CreateConnection(); + + try + { + await dbConnection.OpenAsync(cancellationToken); + + DbTransaction transaction = _options.TransactionIsolationLevel != null + ? await dbConnection.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) + : await dbConnection.BeginTransactionAsync(cancellationToken); + + var transactionId = Guid.NewGuid(); + AmbientTransaction = new AmbientTransaction(this, transaction, transactionId); + + return AmbientTransaction; + } + catch (DbException) + { + await dbConnection.DisposeAsync(); + throw; + } + } + + internal void Detach(AmbientTransaction ambientTransaction) + { + ArgumentGuard.NotNull(ambientTransaction); + + if (AmbientTransaction != null && AmbientTransaction == ambientTransaction) + { + AmbientTransaction = null; + } + else + { + throw new InvalidOperationException("Failed to detach ambient transaction."); + } + } +} diff --git a/src/Examples/DapperExample/Controllers/OperationsController.cs b/src/Examples/DapperExample/Controllers/OperationsController.cs new file mode 100644 index 0000000000..979e6c9cd7 --- /dev/null +++ b/src/Examples/DapperExample/Controllers/OperationsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.Controllers; + +public sealed class OperationsController : JsonApiOperationsController +{ + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + { + } +} diff --git a/src/Examples/DapperExample/DapperExample.csproj b/src/Examples/DapperExample/DapperExample.csproj new file mode 100644 index 0000000000..4445af8c1e --- /dev/null +++ b/src/Examples/DapperExample/DapperExample.csproj @@ -0,0 +1,19 @@ + + + $(TargetFrameworkName) + + + + + + + + + + + + + + + diff --git a/src/Examples/DapperExample/Data/AppDbContext.cs b/src/Examples/DapperExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..ee18bab08e --- /dev/null +++ b/src/Examples/DapperExample/Data/AppDbContext.cs @@ -0,0 +1,81 @@ +using DapperExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +// @formatter:wrap_chained_method_calls chop_always + +namespace DapperExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext +{ + private readonly IConfiguration _configuration; + + public DbSet TodoItems => Set(); + public DbSet People => Set(); + public DbSet LoginAccounts => Set(); + public DbSet AccountRecoveries => Set(); + public DbSet Tags => Set(); + public DbSet RgbColors => Set(); + + public AppDbContext(DbContextOptions options, IConfiguration configuration) + : base(options) + { + ArgumentGuard.NotNull(configuration); + + _configuration = configuration; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(person => person.AssignedTodoItems) + .WithOne(todoItem => todoItem.Assignee); + + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + builder.Entity() + .HasOne(person => person.Account) + .WithOne(loginAccount => loginAccount.Person) + .HasForeignKey("AccountId"); + + builder.Entity() + .HasOne(loginAccount => loginAccount.Recovery) + .WithOne(accountRecovery => accountRecovery.Account) + .HasForeignKey("RecoveryId"); + + builder.Entity() + .HasOne(tag => tag.Color) + .WithOne(rgbColor => rgbColor.Tag) + .HasForeignKey("TagId"); + + var databaseProvider = _configuration.GetValue("DatabaseProvider"); + + if (databaseProvider != DatabaseProvider.SqlServer) + { + // In this example project, all cascades happen in the database, but SQL Server doesn't support that very well. + AdjustDeleteBehaviorForJsonApi(builder); + } + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) + { + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } + + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade) + { + foreignKey.DeleteBehavior = DeleteBehavior.Cascade; + } + } + } +} diff --git a/src/Examples/DapperExample/Data/RotatingList.cs b/src/Examples/DapperExample/Data/RotatingList.cs new file mode 100644 index 0000000000..3fa04762a3 --- /dev/null +++ b/src/Examples/DapperExample/Data/RotatingList.cs @@ -0,0 +1,35 @@ +namespace DapperExample.Data; + +internal abstract class RotatingList +{ + public static RotatingList Create(int count, Func createElement) + { + List elements = new(); + + for (int index = 0; index < count; index++) + { + T element = createElement(index); + elements.Add(element); + } + + return new RotatingList(elements); + } +} + +internal sealed class RotatingList +{ + private int _index = -1; + + public IList Elements { get; } + + public RotatingList(IList elements) + { + Elements = elements; + } + + public T GetNext() + { + _index++; + return Elements[_index % Elements.Count]; + } +} diff --git a/src/Examples/DapperExample/Data/Seeder.cs b/src/Examples/DapperExample/Data/Seeder.cs new file mode 100644 index 0000000000..eb86eca7e8 --- /dev/null +++ b/src/Examples/DapperExample/Data/Seeder.cs @@ -0,0 +1,94 @@ +using DapperExample.Models; +using JetBrains.Annotations; + +namespace DapperExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Seeder +{ + public static async Task CreateSampleDataAsync(AppDbContext dbContext) + { + const int todoItemCount = 500; + const int personCount = 50; + const int accountRecoveryCount = 50; + const int loginAccountCount = 50; + const int tagCount = 25; + const int colorCount = 25; + + RotatingList accountRecoveries = RotatingList.Create(accountRecoveryCount, index => new AccountRecovery + { + PhoneNumber = $"PhoneNumber{index + 1:D2}", + EmailAddress = $"EmailAddress{index + 1:D2}" + }); + + RotatingList loginAccounts = RotatingList.Create(loginAccountCount, index => new LoginAccount + { + UserName = $"UserName{index + 1:D2}", + Recovery = accountRecoveries.GetNext() + }); + + RotatingList people = RotatingList.Create(personCount, index => + { + var person = new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }; + + if (index % 2 == 0) + { + person.Account = loginAccounts.GetNext(); + } + + return person; + }); + + RotatingList colors = + RotatingList.Create(colorCount, index => RgbColor.Create((byte)(index % 255), (byte)(index % 255), (byte)(index % 255))); + + RotatingList tags = RotatingList.Create(tagCount, index => + { + var tag = new Tag + { + Name = $"TagName{index + 1:D2}" + }; + + if (index % 2 == 0) + { + tag.Color = colors.GetNext(); + } + + return tag; + }); + + RotatingList priorities = RotatingList.Create(3, index => (TodoItemPriority)(index + 1)); + + RotatingList todoItems = RotatingList.Create(todoItemCount, index => + { + var todoItem = new TodoItem + { + Description = $"TodoItem{index + 1:D3}", + Priority = priorities.GetNext(), + DurationInHours = index, + CreatedAt = DateTimeOffset.UtcNow, + Owner = people.GetNext(), + Tags = new HashSet + { + tags.GetNext(), + tags.GetNext(), + tags.GetNext() + } + }; + + if (index % 3 == 0) + { + todoItem.Assignee = people.GetNext(); + } + + return todoItem; + }); + + dbContext.TodoItems.AddRange(todoItems.Elements); + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/DapperExample/DatabaseProvider.cs b/src/Examples/DapperExample/DatabaseProvider.cs new file mode 100644 index 0000000000..ea9c293c11 --- /dev/null +++ b/src/Examples/DapperExample/DatabaseProvider.cs @@ -0,0 +1,11 @@ +namespace DapperExample; + +/// +/// Lists the supported databases. +/// +public enum DatabaseProvider +{ + PostgreSql, + MySql, + SqlServer +} diff --git a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000000..77bf6f9548 --- /dev/null +++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using DapperExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; + +namespace DapperExample.Definitions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemDefinition : JsonApiResourceDefinition +{ + private readonly ISystemClock _systemClock; + + public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + ArgumentGuard.NotNull(systemClock); + + _systemClock = systemClock; + } + + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } + + private SortExpression GetDefaultSortOrder() + { + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (todoItem => todoItem.Priority, ListSortDirection.Ascending), + (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) + }); + } + + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + resource.CreatedAt = _systemClock.UtcNow; + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + resource.LastModifiedAt = _systemClock.UtcNow; + } + + return Task.CompletedTask; + } +} diff --git a/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs b/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs new file mode 100644 index 0000000000..8ab88473c1 --- /dev/null +++ b/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs @@ -0,0 +1,46 @@ +using DapperExample.Data; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace DapperExample; + +/// +/// Resolves inverse navigations and initializes from an Entity Framework Core . +/// +internal sealed class FromEntitiesNavigationResolver : IInverseNavigationResolver +{ + private readonly InverseNavigationResolver _defaultResolver; + private readonly FromEntitiesDataModelService _dataModelService; + private readonly DbContext _appDbContext; + + public FromEntitiesNavigationResolver(IResourceGraph resourceGraph, FromEntitiesDataModelService dataModelService, AppDbContext appDbContext) + { + ArgumentGuard.NotNull(resourceGraph); + ArgumentGuard.NotNull(dataModelService); + ArgumentGuard.NotNull(appDbContext); + + _defaultResolver = new InverseNavigationResolver(resourceGraph, new[] + { + new DbContextResolver(appDbContext) + }); + + _dataModelService = dataModelService; + _appDbContext = appDbContext; + } + + public void Resolve() + { + // In order to produce SQL, some knowledge of the underlying database model is required. + // Because the database in this example project is created using Entity Framework Core, we derive that information from its model. + // Some alternative approaches to consider: + // - Query the database to obtain model information at startup. + // - Create a custom attribute that is put on [HasOne/HasMany] resource properties and scan for them at startup. + // - Hard-code the required information in the application. + + _defaultResolver.Resolve(); + _dataModelService.Initialize(_appDbContext); + } +} diff --git a/src/Examples/DapperExample/Models/AccountRecovery.cs b/src/Examples/DapperExample/Models/AccountRecovery.cs new file mode 100644 index 0000000000..38410c203c --- /dev/null +++ b/src/Examples/DapperExample/Models/AccountRecovery.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class AccountRecovery : Identifiable +{ + [Attr] + public string? PhoneNumber { get; set; } + + [Attr] + public string? EmailAddress { get; set; } + + [HasOne] + public LoginAccount Account { get; set; } = null!; +} diff --git a/src/Examples/DapperExample/Models/LoginAccount.cs b/src/Examples/DapperExample/Models/LoginAccount.cs new file mode 100644 index 0000000000..149fc6c7f8 --- /dev/null +++ b/src/Examples/DapperExample/Models/LoginAccount.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class LoginAccount : Identifiable +{ + [Attr] + public string UserName { get; set; } = null!; + + public DateTimeOffset? LastUsedAt { get; set; } + + [HasOne] + public AccountRecovery Recovery { get; set; } = null!; + + [HasOne] + public Person Person { get; set; } = null!; +} diff --git a/src/Examples/DapperExample/Models/Person.cs b/src/Examples/DapperExample/Models/Person.cs new file mode 100644 index 0000000000..1eb4ecadee --- /dev/null +++ b/src/Examples/DapperExample/Models/Person.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + // Mistakenly includes AllowFilter, so we can test for the error produced. + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowFilter)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasOne] + public LoginAccount? Account { get; set; } + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/DapperExample/Models/RgbColor.cs b/src/Examples/DapperExample/Models/RgbColor.cs new file mode 100644 index 0000000000..c29e1b1ba1 --- /dev/null +++ b/src/Examples/DapperExample/Models/RgbColor.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Drawing; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ClientIdGeneration = ClientIdGenerationMode.Required)] +public sealed class RgbColor : Identifiable +{ + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public override int? Id + { + get => base.Id; + set => base.Id = value; + } + + [HasOne] + public Tag Tag { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Red => Id == null ? null : (byte)((Id & 0xFF_0000) >> 16); + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Green => Id == null ? null : (byte)((Id & 0x00_FF00) >> 8); + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Blue => Id == null ? null : (byte)(Id & 0x00_00FF); + + public static RgbColor Create(byte red, byte green, byte blue) + { + Color color = Color.FromArgb(0xFF, red, green, blue); + + return new RgbColor + { + Id = color.ToArgb() & 0x00FF_FFFF + }; + } + + protected override string? GetStringId(int? value) + { + return value?.ToString("X6"); + } + + protected override int? GetTypedId(string? value) + { + return value == null ? null : Convert.ToInt32(value, 16) & 0xFF_FFFF; + } +} diff --git a/src/Examples/DapperExample/Models/Tag.cs b/src/Examples/DapperExample/Models/Tag.cs new file mode 100644 index 0000000000..cb49ff42fb --- /dev/null +++ b/src/Examples/DapperExample/Models/Tag.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasOne] + public RgbColor? Color { get; set; } + + [HasOne] + public TodoItem? TodoItem { get; set; } +} diff --git a/src/Examples/DapperExample/Models/TodoItem.cs b/src/Examples/DapperExample/Models/TodoItem.cs new file mode 100644 index 0000000000..d2f3916268 --- /dev/null +++ b/src/Examples/DapperExample/Models/TodoItem.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class TodoItem : Identifiable +{ + [Attr] + public string Description { get; set; } = null!; + + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } + + [Attr] + public long? DurationInHours { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } + + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { get; set; } + + [HasOne] + public Person Owner { get; set; } = null!; + + [HasOne] + public Person? Assignee { get; set; } + + [HasMany] + public ISet Tags { get; set; } = new HashSet(); +} diff --git a/src/Examples/DapperExample/Models/TodoItemPriority.cs b/src/Examples/DapperExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..ba10336ec3 --- /dev/null +++ b/src/Examples/DapperExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/DapperExample/Program.cs b/src/Examples/DapperExample/Program.cs new file mode 100644 index 0000000000..e19e45478f --- /dev/null +++ b/src/Examples/DapperExample/Program.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; +using DapperExample; +using DapperExample.AtomicOperations; +using DapperExample.Data; +using DapperExample.Models; +using DapperExample.Repositories; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection.Extensions; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.TryAddSingleton(); + +DatabaseProvider databaseProvider = GetDatabaseProvider(builder.Configuration); +string? connectionString = builder.Configuration.GetConnectionString($"DapperExample{databaseProvider}"); + +switch (databaseProvider) +{ + case DatabaseProvider.PostgreSql: + { + builder.Services.AddNpgsql(connectionString, optionsAction: options => SetDbContextDebugOptions(options)); + break; + } + case DatabaseProvider.MySql: + { + builder.Services.AddMySql(connectionString, ServerVersion.AutoDetect(connectionString), + optionsAction: options => SetDbContextDebugOptions(options)); + + break; + } + case DatabaseProvider.SqlServer: + { + builder.Services.AddSqlServer(connectionString, optionsAction: options => SetDbContextDebugOptions(options)); + break; + } +} + +builder.Services.AddScoped(typeof(IResourceRepository<,>), typeof(DapperRepository<,>)); +builder.Services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(DapperRepository<,>)); +builder.Services.AddScoped(typeof(IResourceReadRepository<,>), typeof(DapperRepository<,>)); + +builder.Services.AddJsonApi(options => +{ + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.DefaultPageSize = null; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly(), resourceGraphBuilder => +{ + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); +}); + +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddScoped(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static DatabaseProvider GetDatabaseProvider(IConfiguration configuration) +{ + return configuration.GetValue("DatabaseProvider"); +} + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } +} diff --git a/src/Examples/DapperExample/Properties/AssemblyInfo.cs b/src/Examples/DapperExample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..acbcc24f88 --- /dev/null +++ b/src/Examples/DapperExample/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DapperTests")] diff --git a/src/Examples/DapperExample/Properties/launchSettings.json b/src/Examples/DapperExample/Properties/launchSettings.json new file mode 100644 index 0000000000..137620d860 --- /dev/null +++ b/src/Examples/DapperExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14146", + "sslPort": 44346 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "todoItems?include=owner,assignee,tags&filter=equals(priority,'High')&fields[todoItems]=description,durationInHours", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "todoItems?include=owner,assignee,tags&filter=equals(priority,'High')&fields[todoItems]=description,durationInHours", + "applicationUrl": "https://localhost:44346;http://localhost:14146", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs b/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs new file mode 100644 index 0000000000..294e314eba --- /dev/null +++ b/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs @@ -0,0 +1,22 @@ +using System.Data.Common; +using Dapper; +using DapperExample.AtomicOperations; + +namespace DapperExample.Repositories; + +internal static class CommandDefinitionExtensions +{ + // SQL Server and MySQL require any active DbTransaction to be explicitly associated to the DbConnection. + + public static CommandDefinition Associate(this CommandDefinition command, DbTransaction transaction) + { + return new CommandDefinition(command.CommandText, command.Parameters, transaction, cancellationToken: command.CancellationToken); + } + + public static CommandDefinition Associate(this CommandDefinition command, AmbientTransaction? transaction) + { + return transaction != null + ? new CommandDefinition(command.CommandText, command.Parameters, transaction.Current, cancellationToken: command.CancellationToken) + : command; + } +} diff --git a/src/Examples/DapperExample/Repositories/DapperFacade.cs b/src/Examples/DapperExample/Repositories/DapperFacade.cs new file mode 100644 index 0000000000..d3247967f9 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/DapperFacade.cs @@ -0,0 +1,192 @@ +using Dapper; +using DapperExample.TranslationToSql.Builders; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Constructs Dapper s from SQL trees and handles order of updates. +/// +internal sealed class DapperFacade +{ + private readonly IDataModelService _dataModelService; + + public DapperFacade(IDataModelService dataModelService) + { + ArgumentGuard.NotNull(dataModelService); + + _dataModelService = dataModelService; + } + + public CommandDefinition GetSqlCommand(SqlTreeNode node, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(node); + + var queryBuilder = new SqlQueryBuilder(_dataModelService.DatabaseProvider); + string statement = queryBuilder.GetCommand(node); + IDictionary parameters = queryBuilder.Parameters; + + return new CommandDefinition(statement, parameters, cancellationToken: cancellationToken); + } + + public IReadOnlyCollection BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(ResourceChangeDetector changeDetector, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + List sqlCommands = new(); + + foreach ((HasOneAttribute relationship, (object? currentRightId, object newRightId)) in changeDetector.GetOneToOneRelationshipsChangedToNotNull()) + { + // To prevent a unique constraint violation on the foreign key, first detach/delete the other row pointing to us, if any. + // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + + ResourceType resourceType = foreignKey.IsAtLeftSide ? relationship.LeftType : relationship.RightType; + string whereColumnName = foreignKey.IsAtLeftSide ? foreignKey.ColumnName : TableSourceNode.IdColumnName; + object? whereValue = foreignKey.IsAtLeftSide ? newRightId : currentRightId; + + if (whereValue == null) + { + // Creating new resource, so there can't be any existing FKs in other resources that are already pointing to us. + continue; + } + + if (foreignKey.IsNullable) + { + var updateBuilder = new UpdateClearOneToOneStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(resourceType, foreignKey.ColumnName, whereColumnName, whereValue); + CommandDefinition sqlCommand = GetSqlCommand(updateNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + else + { + var deleteBuilder = new DeleteOneToOneStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(resourceType, whereColumnName, whereValue); + CommandDefinition sqlCommand = GetSqlCommand(deleteNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + } + + return sqlCommands; + } + + public IReadOnlyCollection BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(ResourceChangeDetector changeDetector, + TId leftId, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + List sqlCommands = new(); + + foreach ((HasOneAttribute hasOneRelationship, (object? currentRightId, object? newRightId)) in changeDetector + .GetChangedToOneRelationshipsWithForeignKeyAtRightSide()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = newRightId == null ? null : leftId + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(hasOneRelationship.RightType, columnsToUpdate, (newRightId ?? currentRightId)!); + CommandDefinition sqlCommand = GetSqlCommand(updateNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + + foreach ((HasManyAttribute hasManyRelationship, (ISet currentRightIds, ISet newRightIds)) in changeDetector + .GetChangedToManyRelationships()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasManyRelationship); + + object[] rightIdsToRemove = currentRightIds.Except(newRightIds).ToArray(); + object[] rightIdsToAdd = newRightIds.Except(currentRightIds).ToArray(); + + if (rightIdsToRemove.Any()) + { + CommandDefinition sqlCommand = BuildSqlCommandForRemoveFromToMany(foreignKey, rightIdsToRemove, cancellationToken); + sqlCommands.Add(sqlCommand); + } + + if (rightIdsToAdd.Any()) + { + CommandDefinition sqlCommand = BuildSqlCommandForAddToToMany(foreignKey, leftId!, rightIdsToAdd, cancellationToken); + sqlCommands.Add(sqlCommand); + } + } + + return sqlCommands; + } + + public CommandDefinition BuildSqlCommandForRemoveFromToMany(RelationshipForeignKey foreignKey, object[] rightResourceIdValues, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(foreignKey); + ArgumentGuard.NotNullNorEmpty(rightResourceIdValues); + + if (!foreignKey.IsNullable) + { + var deleteBuilder = new DeleteResourceStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(foreignKey.Relationship.RightType, rightResourceIdValues); + return GetSqlCommand(deleteNode, cancellationToken); + } + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = null + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(foreignKey.Relationship.RightType, columnsToUpdate, rightResourceIdValues); + return GetSqlCommand(updateNode, cancellationToken); + } + + public CommandDefinition BuildSqlCommandForAddToToMany(RelationshipForeignKey foreignKey, object leftId, object[] rightResourceIdValues, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(foreignKey); + ArgumentGuard.NotNull(leftId); + ArgumentGuard.NotNullNorEmpty(rightResourceIdValues); + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = leftId + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(foreignKey.Relationship.RightType, columnsToUpdate, rightResourceIdValues); + return GetSqlCommand(updateNode, cancellationToken); + } + + public CommandDefinition BuildSqlCommandForCreate(ResourceChangeDetector changeDetector, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + IReadOnlyDictionary columnsToSet = changeDetector.GetChangedColumnValues(); + + var insertBuilder = new InsertStatementBuilder(_dataModelService); + InsertNode insertNode = insertBuilder.Build(changeDetector.ResourceType, columnsToSet); + return GetSqlCommand(insertNode, cancellationToken); + } + + public CommandDefinition? BuildSqlCommandForUpdate(ResourceChangeDetector changeDetector, TId leftId, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + IReadOnlyDictionary columnsToUpdate = changeDetector.GetChangedColumnValues(); + + if (columnsToUpdate.Any()) + { + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(changeDetector.ResourceType, columnsToUpdate, leftId!); + return GetSqlCommand(updateNode, cancellationToken); + } + + return null; + } +} diff --git a/src/Examples/DapperExample/Repositories/DapperRepository.cs b/src/Examples/DapperExample/Repositories/DapperRepository.cs new file mode 100644 index 0000000000..bbbeda2ea3 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/DapperRepository.cs @@ -0,0 +1,582 @@ +using System.Data.Common; +using Dapper; +using DapperExample.AtomicOperations; +using DapperExample.TranslationToSql; +using DapperExample.TranslationToSql.Builders; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// A JsonApiDotNetCore resource repository that converts into SQL and uses +/// to execute the SQL and materialize result sets into JSON:API resources. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +/// +/// This implementation has the following limitations: +/// +/// +/// +/// No pagination. Surprisingly, this is insanely complicated and requires non-standard, vendor-specific SQL. +/// +/// +/// +/// +/// No many-to-many relationships. It requires additional information about the database model but should be possible to implement. +/// +/// +/// +/// +/// No resource inheritance. Requires additional information about the database and is complex to implement. +/// +/// +/// +/// +/// No composite primary/foreign keys. It could be implemented, but it's a corner case that few people use. +/// +/// +/// +/// +/// Only parameterless constructors in resource classes. This is because materialization is performed by Dapper, which doesn't support constructors with +/// parameters. +/// +/// +/// +/// +/// Simple change detection in write operations. It includes scalar properties, but relationships go only one level deep. This is sufficient for +/// JSON:API. +/// +/// +/// +/// +/// The database table/column/key name mapping is based on hardcoded conventions. This could be generalized but wasn't done to keep it simple. +/// +/// +/// +/// +/// Cascading deletes are assumed to occur inside the database, which SQL Server does not support very well. This is a lot of work to implement. +/// +/// +/// +/// +/// No [EagerLoad] support. It could be done, but it's rarely used. +/// +/// +/// +/// +/// Untested with self-referencing resources and relationship cycles. +/// +/// +/// +/// +/// No support for . Because no +/// is used, it doesn't apply. +/// +/// +/// +/// +public sealed class DapperRepository : IResourceRepository, IRepositorySupportsTransaction + where TResource : class, IIdentifiable +{ + private readonly ITargetedFields _targetedFields; + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly AmbientTransactionFactory _transactionFactory; + private readonly IDataModelService _dataModelService; + private readonly SqlCaptureStore _captureStore; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger> _logger; + private readonly CollectionConverter _collectionConverter = new(); + private readonly ParameterFormatter _parameterFormatter = new(); + private readonly DapperFacade _dapperFacade; + + private ResourceType ResourceType => _resourceGraph.GetResourceType(); + + public string? TransactionId => _transactionFactory.AmbientTransaction?.TransactionId; + + public DapperRepository(ITargetedFields targetedFields, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor, AmbientTransactionFactory transactionFactory, IDataModelService dataModelService, + SqlCaptureStore captureStore, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(targetedFields); + ArgumentGuard.NotNull(resourceGraph); + ArgumentGuard.NotNull(resourceFactory); + ArgumentGuard.NotNull(resourceDefinitionAccessor); + ArgumentGuard.NotNull(transactionFactory); + ArgumentGuard.NotNull(dataModelService); + ArgumentGuard.NotNull(captureStore); + ArgumentGuard.NotNull(loggerFactory); + + _targetedFields = targetedFields; + _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _transactionFactory = transactionFactory; + _dataModelService = dataModelService; + _captureStore = captureStore; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger>(); + _dapperFacade = new DapperFacade(dataModelService); + } + + /// + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(queryLayer); + + var mapper = new ResultSetMapper(queryLayer.Include); + + var selectBuilder = new SelectStatementBuilder(_dataModelService, _loggerFactory); + SelectNode selectNode = selectBuilder.Build(queryLayer, SelectShape.Columns); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); + LogSqlCommand(sqlCommand); + + IReadOnlyCollection resources = await ExecuteQueryAsync(async connection => + { + // Reads must occur within the active transaction, when in an atomic:operations request. + sqlCommand = sqlCommand.Associate(_transactionFactory.AmbientTransaction); + + // Unfortunately, there's no CancellationToken support. See https://github.com/DapperLib/Dapper/issues/1181. + _ = await connection.QueryAsync(sqlCommand.CommandText, mapper.ResourceClrTypes, mapper.Map, sqlCommand.Parameters, sqlCommand.Transaction); + + return mapper.GetResources(); + }, cancellationToken); + + return resources; + } + + /// + public async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(ResourceType) + { + Filter = filter + }; + + var selectBuilder = new SelectStatementBuilder(_dataModelService, _loggerFactory); + SelectNode selectNode = selectBuilder.Build(queryLayer, SelectShape.Count); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); + LogSqlCommand(sqlCommand); + + return await ExecuteQueryAsync(async connection => await connection.ExecuteScalarAsync(sqlCommand), cancellationToken); + } + + /// + public Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceClrType); + + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); + resource.Id = id; + + return Task.FromResult(resource); + } + + /// + public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceFromRequest); + ArgumentGuard.NotNull(resourceForDatabase); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + + await ApplyTargetedFieldsAsync(resourceFromRequest, resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + changeDetector.CaptureNewValues(resourceForDatabase); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition insertCommand = _dapperFacade.BuildSqlCommandForCreate(changeDetector, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new Exception("Multiple rows found.")); + } + } + + LogSqlCommand(insertCommand); + resourceForDatabase.Id = (await transaction.Connection!.ExecuteScalarAsync(insertCommand.Associate(transaction)))!; + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, resourceForDatabase.Id, cancellationToken); + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new Exception("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + } + + private async Task ApplyTargetedFieldsAsync(TResource resourceFromRequest, TResource resourceInDatabase, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceInDatabase, relationship, rightValue, writeOperation, cancellationToken); + + relationship.SetValue(resourceInDatabase, rightValueEvaluated); + } + + foreach (AttrAttribute attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceInDatabase, attribute.GetValue(resourceFromRequest)); + } + } + + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, writeOperation, + cancellationToken); + } + + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, + cancellationToken); + + return _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + } + + return rightValue; + } + + /// + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(queryLayer); + + IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); + return resources.FirstOrDefault(); + } + + /// + public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceFromRequest); + ArgumentGuard.NotNull(resourceFromDatabase); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + changeDetector.CaptureCurrentValues(resourceFromDatabase); + + await ApplyTargetedFieldsAsync(resourceFromRequest, resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + changeDetector.CaptureNewValues(resourceFromDatabase); + changeDetector.AssertIsNotClearingAnyRequiredToOneRelationships(ResourceType.PublicName); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition? updateCommand = _dapperFacade.BuildSqlCommandForUpdate(changeDetector, resourceFromDatabase.Id, cancellationToken); + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, resourceFromDatabase.Id, cancellationToken); + + if (preSqlCommands.Any() || updateCommand != null || postSqlCommands.Any()) + { + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new Exception("Multiple rows found.")); + } + } + + if (updateCommand != null) + { + LogSqlCommand(updateCommand.Value); + int rowsAffected = await transaction.Connection!.ExecuteAsync(updateCommand.Value.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + } + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new Exception("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + } + } + + /// + public async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) + { + TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance(); + placeholderResource.Id = id; + + await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + + var deleteBuilder = new DeleteResourceStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(ResourceType, placeholderResource.Id!); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(deleteNode, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + } + + /// + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(leftResource); + + RelationshipAttribute relationship = _targetedFields.Relationships.Single(); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + changeDetector.CaptureCurrentValues(leftResource); + + object? rightValueEvaluated = + await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); + + relationship.SetValue(leftResource, rightValueEvaluated); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + + changeDetector.CaptureNewValues(leftResource); + changeDetector.AssertIsNotClearingAnyRequiredToOneRelationships(ResourceType.PublicName); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition? updateCommand = _dapperFacade.BuildSqlCommandForUpdate(changeDetector, leftResource.Id, cancellationToken); + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, leftResource.Id, cancellationToken); + + if (preSqlCommands.Any() || updateCommand != null || postSqlCommands.Any()) + { + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new Exception("Multiple rows found.")); + } + } + + if (updateCommand != null) + { + LogSqlCommand(updateCommand.Value); + int rowsAffected = await transaction.Connection!.ExecuteAsync(updateCommand.Value.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + } + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new Exception("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + } + } + + /// + public async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(rightResourceIds); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + + TResource leftPlaceholderResource = leftResource ?? _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; + + await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); + relationship.SetValue(leftPlaceholderResource, _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + await _resourceDefinitionAccessor.OnWritingAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); + + if (rightResourceIds.Any()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + object[] rightResourceIdValues = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + + CommandDefinition sqlCommand = + _dapperFacade.BuildSqlCommandForAddToToMany(foreignKey, leftPlaceholderResource.Id!, rightResourceIdValues, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != rightResourceIdValues.Length) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); + } + } + + /// + public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(leftResource); + ArgumentGuard.NotNull(rightResourceIds); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + + await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIds, cancellationToken); + relationship.SetValue(leftResource, _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + + if (rightResourceIds.Any()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + object[] rightResourceIdValues = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + CommandDefinition sqlCommand = _dapperFacade.BuildSqlCommandForRemoveFromToMany(foreignKey, rightResourceIdValues, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != rightResourceIdValues.Length) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + } + } + + private void LogSqlCommand(CommandDefinition command) + { + var parameters = (IDictionary?)command.Parameters; + + _captureStore.Add(command.CommandText, parameters); + + string message = GetLogText(command.CommandText, parameters); + _logger.LogInformation(message); + } + + private string GetLogText(string statement, IDictionary? parameters) + { + if (parameters?.Any() == true) + { + string parametersText = string.Join(", ", parameters.Select(parameter => _parameterFormatter.Format(parameter.Key, parameter.Value))); + return $"Executing SQL with parameters: {parametersText}{Environment.NewLine}{statement}"; + } + + return $"Executing SQL: {Environment.NewLine}{statement}"; + } + + private async Task ExecuteQueryAsync(Func> asyncAction, CancellationToken cancellationToken) + { + if (_transactionFactory.AmbientTransaction != null) + { + DbConnection connection = _transactionFactory.AmbientTransaction.Current.Connection!; + return await asyncAction(connection); + } + + await using DbConnection dbConnection = _dataModelService.CreateConnection(); + await dbConnection.OpenAsync(cancellationToken); + + return await asyncAction(dbConnection); + } + + private async Task ExecuteInTransactionAsync(Func asyncAction, CancellationToken cancellationToken) + { + try + { + if (_transactionFactory.AmbientTransaction != null) + { + await asyncAction(_transactionFactory.AmbientTransaction.Current); + } + else + { + await using AmbientTransaction transaction = await _transactionFactory.BeginTransactionAsync(cancellationToken); + await asyncAction(transaction.Current); + + await transaction.CommitAsync(cancellationToken); + } + } + catch (DbException exception) + { + throw new DataStoreUpdateException(exception); + } + } +} diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs new file mode 100644 index 0000000000..22da724ae2 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -0,0 +1,219 @@ +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// A simplistic change detector. Detects changes in scalar properties, but relationship changes only one level deep. +/// +internal sealed class ResourceChangeDetector +{ + private readonly CollectionConverter _collectionConverter = new(); + private readonly IDataModelService _dataModelService; + + private Dictionary _currentColumnValues = new(); + private Dictionary _newColumnValues = new(); + + private Dictionary> _currentRightResourcesByRelationship = new(); + private Dictionary> _newRightResourcesByRelationship = new(); + + public ResourceType ResourceType { get; } + + public ResourceChangeDetector(ResourceType resourceType, IDataModelService dataModelService) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(dataModelService); + + ResourceType = resourceType; + _dataModelService = dataModelService; + } + + public void CaptureCurrentValues(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource); + AssertSameType(ResourceType, resource); + + _currentColumnValues = CaptureColumnValues(resource); + _currentRightResourcesByRelationship = CaptureRightResourcesByRelationship(resource); + } + + public void CaptureNewValues(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource); + AssertSameType(ResourceType, resource); + + _newColumnValues = CaptureColumnValues(resource); + _newRightResourcesByRelationship = CaptureRightResourcesByRelationship(resource); + } + + private Dictionary CaptureColumnValues(IIdentifiable resource) + { + Dictionary columnValues = new(); + + foreach ((string columnName, ResourceFieldAttribute? _) in _dataModelService.GetColumnMappings(ResourceType)) + { + columnValues[columnName] = _dataModelService.GetColumnValue(ResourceType, resource, columnName); + } + + return columnValues; + } + + private Dictionary> CaptureRightResourcesByRelationship(IIdentifiable resource) + { + Dictionary> relationshipValues = new(); + + foreach (RelationshipAttribute relationship in ResourceType.Relationships) + { + object? rightValue = relationship.GetValue(resource); + HashSet rightResources = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + relationshipValues[relationship] = rightResources; + } + + return relationshipValues; + } + + public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName) + { + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (!foreignKey.IsNullable) + { + object? currentRightId = + _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out ISet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + bool hasChanged = !Equals(currentRightId, newRightId); + + if (hasChanged && newRightId == null) + { + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); + } + } + } + } + } + + public IReadOnlyDictionary GetOneToOneRelationshipsChangedToNotNull() + { + Dictionary changes = new(); + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute { IsOneToOne: true } hasOneRelationship) + { + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + if (newRightId != null) + { + object? currentRightId = + _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out ISet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + if (!Equals(currentRightId, newRightId)) + { + changes[hasOneRelationship] = (currentRightId, newRightId); + } + } + } + } + + return changes; + } + + public IReadOnlyDictionary GetChangedColumnValues() + { + Dictionary changes = new(); + + foreach ((string columnName, object? newColumnValue) in _newColumnValues) + { + bool currentFound = _currentColumnValues.TryGetValue(columnName, out object? currentColumnValue); + + if (!currentFound || !Equals(currentColumnValue, newColumnValue)) + { + changes[columnName] = newColumnValue; + } + } + + return changes; + } + + public IReadOnlyDictionary GetChangedToOneRelationshipsWithForeignKeyAtRightSide() + { + Dictionary changes = new(); + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (foreignKey.IsAtLeftSide) + { + continue; + } + + object? currentRightId = _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out ISet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + if (!Equals(currentRightId, newRightId)) + { + changes[hasOneRelationship] = (currentRightId, newRightId); + } + } + } + + return changes; + } + + public IReadOnlyDictionary currentRightIds, ISet newRightIds)> GetChangedToManyRelationships() + { + Dictionary currentRightIds, ISet newRightIds)> changes = new(); + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet newRightIds = newRightResources.Select(resource => resource.GetTypedId()).ToHashSet(); + + HashSet currentRightIds = + _currentRightResourcesByRelationship.TryGetValue(hasManyRelationship, out ISet? currentRightResources) + ? currentRightResources.Select(resource => resource.GetTypedId()).ToHashSet() + : new HashSet(); + + if (!currentRightIds.SetEquals(newRightIds)) + { + changes[hasManyRelationship] = (currentRightIds, newRightIds); + } + } + } + + return changes; + } + + private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) + { + Type declaredType = resourceType.ClrType; + Type instanceType = resource.GetType(); + + if (instanceType != declaredType) + { + throw new ArgumentException($"Expected resource of type '{declaredType.Name}' instead of '{instanceType.Name}'.", nameof(resource)); + } + } +} diff --git a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs new file mode 100644 index 0000000000..e0a6efddd0 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs @@ -0,0 +1,197 @@ +using System.Reflection; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Maps the result set from a SQL query that includes primary and related resources. +/// +internal sealed class ResultSetMapper + where TResource : class, IIdentifiable +{ + private readonly List _joinObjectTypes = new(); + + // For each object type, we keep a map of ID/instance pairs. + // Note we don't do full bidirectional relationship fix-up; this just avoids duplicate instances. + private readonly Dictionary> _resourceByTypeCache = new(); + + // Optimization to avoid unneeded calls to expensive Activator.CreateInstance() method, which is needed multiple times per row. + private readonly Dictionary _defaultValueByTypeCache = new(); + + // Used to determine where in the tree of included relationships a join object belongs to. + private readonly Dictionary _includeElementToJoinObjectArrayIndexLookup = new(ReferenceEqualityComparer.Instance); + + // The return value of the mapping process. + private readonly List _primaryResourcesInOrder = new(); + + // The included relationships for which an INNER/LEFT JOIN statement was produced, which we're mapping. + private readonly IncludeExpression _include; + + public Type[] ResourceClrTypes => _joinObjectTypes.ToArray(); + + public ResultSetMapper(IncludeExpression? include) + { + _include = include ?? IncludeExpression.Empty; + _joinObjectTypes.Add(typeof(TResource)); + _resourceByTypeCache[typeof(TResource)] = new Dictionary(); + + var walker = new IncludeElementWalker(_include); + int index = 1; + + foreach (IncludeElementExpression includeElement in walker.BreadthFirstEnumerate()) + { + _joinObjectTypes.Add(includeElement.Relationship.RightType.ClrType); + _resourceByTypeCache[includeElement.Relationship.RightType.ClrType] = new Dictionary(); + _includeElementToJoinObjectArrayIndexLookup[includeElement] = index; + + index++; + } + } + + public object? Map(object[] joinObjects) + { + // This method executes for each row in the SQL result set. + + if (joinObjects.Length != _includeElementToJoinObjectArrayIndexLookup.Count + 1) + { + throw new InvalidOperationException("Failed to properly map SQL result set into objects."); + } + + object?[] objectsCached = joinObjects.Select(GetCached).ToArray(); + var leftResource = (TResource?)objectsCached[0]; + + if (leftResource == null) + { + throw new InvalidOperationException("Failed to properly map SQL result set into objects."); + } + + RecursiveSetRelationships(leftResource, _include.Elements, objectsCached); + + _primaryResourcesInOrder.Add(leftResource); + return null; + } + + private object? GetCached(object? resource) + { + if (resource == null) + { + return null; + } + + object? resourceId = GetResourceId(resource); + + if (resourceId == null || HasDefaultValue(resourceId)) + { + // When Id is not set, the entire object is empty (due to LEFT JOIN usage). + return null; + } + + Dictionary resourceByIdCache = _resourceByTypeCache[resource.GetType()]; + + if (resourceByIdCache.TryGetValue(resourceId, out object? cachedValue)) + { + return cachedValue; + } + + resourceByIdCache[resourceId] = resource; + return resource; + } + + private static object? GetResourceId(object resource) + { + PropertyInfo? property = resource.GetType().GetProperty(TableSourceNode.IdColumnName); + + if (property == null) + { + throw new InvalidOperationException($"{TableSourceNode.IdColumnName} property not found on object of type '{resource.GetType().Name}'."); + } + + return property.GetValue(resource); + } + + private bool HasDefaultValue(object value) + { + object? defaultValue = GetDefaultValueCached(value.GetType()); + return Equals(defaultValue, value); + } + + private object? GetDefaultValueCached(Type type) + { + if (_defaultValueByTypeCache.TryGetValue(type, out object? defaultValue)) + { + return defaultValue; + } + + defaultValue = RuntimeTypeConverter.GetDefaultValue(type); + _defaultValueByTypeCache[type] = defaultValue; + return defaultValue; + } + + private void RecursiveSetRelationships(object leftResource, IEnumerable includeElements, object?[] joinObjects) + { + foreach (IncludeElementExpression includeElement in includeElements) + { + int rightIndex = _includeElementToJoinObjectArrayIndexLookup[includeElement]; + object? rightResource = joinObjects[rightIndex]; + + SetRelationship(leftResource, includeElement.Relationship, rightResource); + + if (rightResource != null && includeElement.Children.Any()) + { + RecursiveSetRelationships(rightResource, includeElement.Children, joinObjects); + } + } + } + + private void SetRelationship(object leftResource, RelationshipAttribute relationship, object? rightResource) + { + if (rightResource != null) + { + if (relationship is HasManyAttribute hasManyRelationship) + { + hasManyRelationship.AddValue(leftResource, (IIdentifiable)rightResource); + } + else + { + relationship.SetValue(leftResource, rightResource); + } + } + } + + public IReadOnlyCollection GetResources() + { + return _primaryResourcesInOrder.DistinctBy(resource => resource.Id).ToList(); + } + + private sealed class IncludeElementWalker + { + private readonly IncludeExpression _include; + + public IncludeElementWalker(IncludeExpression include) + { + _include = include; + } + + public IEnumerable BreadthFirstEnumerate() + { + foreach (IncludeElementExpression next in _include.Elements.OrderBy(element => element.Relationship.PublicName) + .SelectMany(RecursiveEnumerateElement)) + { + yield return next; + } + } + + private IEnumerable RecursiveEnumerateElement(IncludeElementExpression element) + { + yield return element; + + foreach (IncludeElementExpression next in element.Children.OrderBy(child => child.Relationship.PublicName).SelectMany(RecursiveEnumerateElement)) + { + yield return next; + } + } + } +} diff --git a/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs new file mode 100644 index 0000000000..272dedbd37 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs @@ -0,0 +1,26 @@ +using DapperExample.TranslationToSql; +using JetBrains.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Captures the emitted SQL statements, which enables integration tests to assert on them. +/// +[PublicAPI] +public sealed class SqlCaptureStore +{ + private readonly List _sqlCommands = new(); + + public IReadOnlyList SqlCommands => _sqlCommands; + + public void Clear() + { + _sqlCommands.Clear(); + } + + internal void Add(string statement, IDictionary? parameters) + { + var sqlCommand = new SqlCommand(statement, parameters ?? new Dictionary()); + _sqlCommands.Add(sqlCommand); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs new file mode 100644 index 0000000000..5a1293d41b --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs @@ -0,0 +1,37 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class DeleteOneToOneStatementBuilder : StatementBuilder +{ + public DeleteOneToOneStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public DeleteNode Build(ResourceType resourceType, string whereColumnName, object? whereValue) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(whereColumnName); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode column = table.GetColumn(whereColumnName, null, table.Alias); + WhereNode where = GetWhere(column, whereValue); + + return new DeleteNode(table, where); + } + + private WhereNode GetWhere(ColumnNode column, object? value) + { + ParameterNode parameter = ParameterGenerator.Create(value); + var filter = new ComparisonNode(ComparisonOperator.Equals, column, parameter); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs new file mode 100644 index 0000000000..41794e8883 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs @@ -0,0 +1,37 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class DeleteResourceStatementBuilder : StatementBuilder +{ + public DeleteResourceStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public DeleteNode Build(ResourceType resourceType, params object[] idValues) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNullNorEmpty(idValues); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode idColumn = table.GetIdColumn(table.Alias); + WhereNode where = GetWhere(idColumn, idValues); + + return new DeleteNode(table, where); + } + + private WhereNode GetWhere(ColumnNode idColumn, IEnumerable idValues) + { + List parameters = idValues.Select(idValue => ParameterGenerator.Create(idValue)).ToList(); + FilterNode filter = parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, idColumn, parameters[0]) : new InNode(idColumn, parameters); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs new file mode 100644 index 0000000000..b362a0e7b4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs @@ -0,0 +1,55 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class InsertStatementBuilder : StatementBuilder +{ + public InsertStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public InsertNode Build(ResourceType resourceType, IReadOnlyDictionary columnsToSet) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(columnsToSet); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + List assignments = GetColumnAssignments(columnsToSet, table); + + return new InsertNode(table, assignments); + } + + private List GetColumnAssignments(IReadOnlyDictionary columnsToSet, TableNode table) + { + List assignments = new(); + ColumnNode idColumn = table.GetIdColumn(table.Alias); + + foreach ((string columnName, object? columnValue) in columnsToSet) + { + if (columnName == idColumn.Name) + { + object? defaultIdValue = columnValue == null ? null : RuntimeTypeConverter.GetDefaultValue(columnValue.GetType()); + + if (Equals(columnValue, defaultIdValue)) + { + continue; + } + } + + ColumnNode column = table.GetColumn(columnName, null, table.Alias); + ParameterNode parameter = ParameterGenerator.Create(columnValue); + + var assignment = new ColumnAssignmentNode(column, parameter); + assignments.Add(assignment); + } + + return assignments; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs new file mode 100644 index 0000000000..d4fdd09b69 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Indicates what to select in a SELECT statement. +/// +internal enum SelectShape +{ + /// + /// Select a set of columns. + /// + Columns, + + /// + /// Select the number of rows: COUNT(*). + /// + Count, + + /// + /// Select only the first, unnamed column: SELECT 1. + /// + One +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs new file mode 100644 index 0000000000..4e12b735c7 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -0,0 +1,786 @@ +using System.Net; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.Generators; +using DapperExample.TranslationToSql.Transformations; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Builds a SELECT statement from a . +/// +internal sealed class SelectStatementBuilder : QueryExpressionVisitor +{ + // State that is shared between sub-queries. + private readonly QueryState _queryState; + + // The FROM/JOIN/sub-SELECT tables, along with their selectors (which usually are column references). + private readonly Dictionary> _selectorsPerTable; + + // Used to assign unique names when adding selectors, in case tables are joined that would result in duplicate column names. + private readonly HashSet _selectorNamesUsed; + + // Filter constraints. + private readonly List _whereFilters; + + // Sorting on columns, or COUNT(*) in a sub-query. + private readonly List _orderByTerms; + + // Indicates whether to select a set of columns, the number of rows, or only the first (unnamed) column. + private SelectShape _selectShape; + + public SelectStatementBuilder(IDataModelService dataModelService, ILoggerFactory loggerFactory) + : this(new QueryState(dataModelService, new TableAliasGenerator(), new ParameterGenerator(), loggerFactory)) + { + } + + private SelectStatementBuilder(QueryState queryState) + { + _queryState = queryState; + _selectorsPerTable = new Dictionary>(); + _selectorNamesUsed = new HashSet(); + _whereFilters = new List(); + _orderByTerms = new List(); + } + + public SelectNode Build(QueryLayer queryLayer, SelectShape selectShape) + { + ArgumentGuard.NotNull(queryLayer); + + // Convert queryLayer.Include into multiple levels of queryLayer.Selection. + var includeConverter = new QueryLayerIncludeConverter(queryLayer); + includeConverter.ConvertIncludesToSelections(); + + ResetState(selectShape); + + TableAccessorNode primaryTableAccessor = CreatePrimaryTable(queryLayer.ResourceType); + ConvertQueryLayer(queryLayer, primaryTableAccessor); + + SelectNode select = ToSelect(false, false); + + if (_selectShape == SelectShape.Columns) + { + var staleRewriter = new StaleColumnReferenceRewriter(_queryState.OldToNewTableAliasMap, _queryState.LoggerFactory); + select = staleRewriter.PullColumnsIntoScope(select); + + var selectorsRewriter = new UnusedSelectorsRewriter(_queryState.LoggerFactory); + select = selectorsRewriter.RemoveUnusedSelectorsInSubQueries(select); + } + + return select; + } + + private void ResetState(SelectShape selectShape) + { + _queryState.Reset(); + _selectorsPerTable.Clear(); + _selectorNamesUsed.Clear(); + _whereFilters.Clear(); + _orderByTerms.Clear(); + _selectShape = selectShape; + } + + private TableAccessorNode CreatePrimaryTable(ResourceType resourceType) + { + IReadOnlyDictionary columnMappings = _queryState.DataModelService.GetColumnMappings(resourceType); + var table = new TableNode(resourceType, columnMappings, _queryState.TableAliasGenerator.GetNext()); + var from = new FromNode(table); + + TrackPrimaryTable(from); + return from; + } + + private void TrackPrimaryTable(TableAccessorNode tableAccessor) + { + if (_selectorsPerTable.Count > 0) + { + throw new InvalidOperationException("A primary table already exists."); + } + + _queryState.RelatedTables.Add(tableAccessor, new Dictionary()); + + _selectorsPerTable[tableAccessor] = _selectShape switch + { + SelectShape.Columns => Array.Empty(), + SelectShape.Count => new CountSelectorNode(null).AsArray(), + _ => new OneSelectorNode(null).AsArray() + }; + } + + private void ConvertQueryLayer(QueryLayer queryLayer, TableAccessorNode tableAccessor) + { + if (queryLayer.Filter != null) + { + var filter = (FilterNode)Visit(queryLayer.Filter, tableAccessor); + _whereFilters.Add(filter); + } + + if (queryLayer.Sort != null) + { + var orderBy = (OrderByNode)Visit(queryLayer.Sort, tableAccessor); + _orderByTerms.AddRange(orderBy.Terms); + } + + if (queryLayer.Pagination is { PageSize: not null }) + { + throw new NotSupportedException("Pagination is not supported."); + } + + if (queryLayer.Selection != null) + { + foreach (ResourceType resourceType in queryLayer.Selection.GetResourceTypes()) + { + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(resourceType); + ConvertFieldSelectors(selectors, tableAccessor); + } + } + } + + private void ConvertFieldSelectors(FieldSelectors selectors, TableAccessorNode tableAccessor) + { + HashSet selectedColumns = new(); + Dictionary nextLayers = new(); + + if (selectors.IsEmpty || selectors.ContainsReadOnlyAttribute || selectors.ContainsOnlyRelationships) + { + // 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. + // Additionally, empty selectors (originating from eliminated includes) indicate to fetch all scalar properties too. + + selectedColumns = tableAccessor.Source.Columns.Where(column => column.Type == ColumnType.Scalar).ToHashSet(); + } + + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in selectors.OrderBy(selector => selector.Key.PublicName)) + { + if (field is AttrAttribute attribute) + { + // Returns null when the set contains an unmapped column, which is silently ignored. + ColumnNode? column = tableAccessor.Source.FindColumn(attribute.Property.Name, ColumnType.Scalar, tableAccessor.Source.Alias); + + if (column != null) + { + selectedColumns.Add(column); + } + } + + if (field is RelationshipAttribute relationship && nextLayer != null) + { + nextLayers.Add(relationship, nextLayer); + } + } + + if (_selectShape == SelectShape.Columns) + { + SetColumnSelectors(tableAccessor, selectedColumns); + } + + foreach ((RelationshipAttribute relationship, QueryLayer nextLayer) in nextLayers) + { + ConvertNestedQueryLayer(tableAccessor, relationship, nextLayer); + } + } + + private void SetColumnSelectors(TableAccessorNode tableAccessor, IEnumerable columns) + { + if (!_selectorsPerTable.ContainsKey(tableAccessor)) + { + throw new InvalidOperationException($"Table {tableAccessor.Source.Alias} not found in selected tables."); + } + + // When selecting from a table, use a deterministic order to simplify test assertions. + // When selecting from a sub-query (typically spanning multiple tables and renamed columns), existing order must be preserved. + _selectorsPerTable[tableAccessor] = tableAccessor.Source is SelectNode + ? PreserveColumnOrderEnsuringUniqueNames(columns) + : OrderColumnsWithIdAtFrontEnsuringUniqueNames(columns); + } + + private List PreserveColumnOrderEnsuringUniqueNames(IEnumerable columns) + { + List selectors = new(); + + foreach (ColumnNode column in columns) + { + string uniqueName = GetUniqueSelectorName(column.Name); + string? selectorAlias = uniqueName != column.Name ? uniqueName : null; + var columnSelector = new ColumnSelectorNode(column, selectorAlias); + selectors.Add(columnSelector); + } + + return selectors; + } + + private List OrderColumnsWithIdAtFrontEnsuringUniqueNames(IEnumerable columns) + { + Dictionary> selectorsPerTable = new(); + + foreach (ColumnNode column in columns.OrderBy(column => column.GetTableAliasIndex()).ThenBy(column => column.Name)) + { + string tableAlias = column.TableAlias ?? "!"; + selectorsPerTable.TryAdd(tableAlias, new List()); + + string uniqueName = GetUniqueSelectorName(column.Name); + string? selectorAlias = uniqueName != column.Name ? uniqueName : null; + var columnSelector = new ColumnSelectorNode(column, selectorAlias); + + if (column.Name == TableSourceNode.IdColumnName) + { + selectorsPerTable[tableAlias].Insert(0, columnSelector); + } + else + { + selectorsPerTable[tableAlias].Add(columnSelector); + } + } + + return selectorsPerTable.SelectMany(selector => selector.Value).ToList(); + } + + private string GetUniqueSelectorName(string columnName) + { + string uniqueName = columnName; + + while (_selectorNamesUsed.Contains(uniqueName)) + { + uniqueName += "0"; + } + + _selectorNamesUsed.Add(uniqueName); + return uniqueName; + } + + private void ConvertNestedQueryLayer(TableAccessorNode tableAccessor, RelationshipAttribute relationship, QueryLayer nextLayer) + { + bool requireSubQuery = nextLayer.Filter != null; + + if (requireSubQuery) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState); + + TableAccessorNode primaryTableAccessor = subSelectBuilder.CreatePrimaryTable(relationship.RightType); + subSelectBuilder.ConvertQueryLayer(nextLayer, primaryTableAccessor); + + string[] innerTableAliases = subSelectBuilder._selectorsPerTable.Keys.Select(accessor => accessor.Source.Alias).Cast().ToArray(); + + // In the sub-query, select all columns, to enable referencing them from other locations in the query. + // This usually produces unused selectors, which will be removed in a post-processing step. + var selectorsToKeep = new Dictionary>(subSelectBuilder._selectorsPerTable); + subSelectBuilder.SelectAllColumnsInAllTables(selectorsToKeep.Keys); + + // Since there's no pagination support, it's pointless to preserve orderings in the sub-query. + List orderingsToKeep = subSelectBuilder._orderByTerms.ToList(); + subSelectBuilder._orderByTerms.Clear(); + + SelectNode aliasedSubQuery = subSelectBuilder.ToSelect(true, true); + + // Store inner-to-outer table aliases, to enable rewriting stale column references in a post-processing step. + // This is required for orderings that contain sub-selects, resulting from order-by-count. + MapOldTableAliasesToSubQuery(innerTableAliases, aliasedSubQuery.Alias!); + + TableAccessorNode outerTableAccessor = CreateRelatedTable(tableAccessor, relationship, aliasedSubQuery); + + // In the outer query, select only what was originally selected. + _selectorsPerTable[outerTableAccessor] = MapSelectorsFromSubQuery(selectorsToKeep.SelectMany(selector => selector.Value), aliasedSubQuery); + + // To achieve total ordering, all orderings from sub-query must always appear in the root query. + IReadOnlyList outerOrderingsToAdd = MapOrderingsFromSubQuery(orderingsToKeep, aliasedSubQuery); + _orderByTerms.AddRange(outerOrderingsToAdd); + } + else + { + TableAccessorNode relatedTableAccessor = GetOrCreateRelatedTable(tableAccessor, relationship); + ConvertQueryLayer(nextLayer, relatedTableAccessor); + } + } + + private void SelectAllColumnsInAllTables(IEnumerable tableAccessors) + { + _selectorsPerTable.Clear(); + _selectorNamesUsed.Clear(); + + foreach (TableAccessorNode tableAccessor in tableAccessors) + { + _selectorsPerTable.Add(tableAccessor, Array.Empty()); + + if (_selectShape == SelectShape.Columns) + { + SetColumnSelectors(tableAccessor, tableAccessor.Source.Columns); + } + } + } + + private void MapOldTableAliasesToSubQuery(IEnumerable oldTableAliases, string newTableAlias) + { + foreach (string oldTableAlias in oldTableAliases) + { + _queryState.OldToNewTableAliasMap[oldTableAlias] = newTableAlias; + } + } + + private TableAccessorNode CreateRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableSourceNode rightTableSource) + { + RelationshipForeignKey foreignKey = _queryState.DataModelService.GetForeignKey(relationship); + JoinType joinType = foreignKey is { IsAtLeftSide: true, IsNullable: false } ? JoinType.InnerJoin : JoinType.LeftJoin; + + ComparisonNode joinCondition = CreateJoinCondition(leftTableAccessor.Source, relationship, rightTableSource); + + TableAccessorNode relatedTableAccessor = new JoinNode(joinType, rightTableSource, (ColumnNode)joinCondition.Left, (ColumnNode)joinCondition.Right); + + TrackRelatedTable(leftTableAccessor, relationship, relatedTableAccessor); + return relatedTableAccessor; + } + + private ComparisonNode CreateJoinCondition(TableSourceNode outerTableSource, RelationshipAttribute relationship, TableSourceNode innerTableSource) + { + RelationshipForeignKey foreignKey = _queryState.DataModelService.GetForeignKey(relationship); + + ColumnNode innerColumn = foreignKey.IsAtLeftSide + ? innerTableSource.GetIdColumn(innerTableSource.Alias) + : innerTableSource.GetColumn(foreignKey.ColumnName, ColumnType.ForeignKey, innerTableSource.Alias); + + ColumnNode outerColumn = foreignKey.IsAtLeftSide + ? outerTableSource.GetColumn(foreignKey.ColumnName, ColumnType.ForeignKey, outerTableSource.Alias) + : outerTableSource.GetIdColumn(outerTableSource.Alias); + + return new ComparisonNode(ComparisonOperator.Equals, outerColumn, innerColumn); + } + + private void TrackRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableAccessorNode rightTableAccessor) + { + _queryState.RelatedTables.Add(rightTableAccessor, new Dictionary()); + _selectorsPerTable[rightTableAccessor] = Array.Empty(); + + _queryState.RelatedTables[leftTableAccessor].Add(relationship, rightTableAccessor); + } + + private IReadOnlyList MapSelectorsFromSubQuery(IEnumerable innerSelectorsToKeep, SelectNode select) + { + List outerColumnsToKeep = new(); + + foreach (SelectorNode innerSelector in innerSelectorsToKeep) + { + if (innerSelector is ColumnSelectorNode innerColumnSelector) + { + // t2."Id" AS Id0 => t3.Id0 + ColumnNode innerColumn = innerColumnSelector.Column; + ColumnNode outerColumn = select.Columns.Single(outerColumn => outerColumn.Selector.Column == innerColumn); + outerColumnsToKeep.Add(outerColumn); + } + else + { + // If there's an alias, we should use it. Otherwise we could fallback to ordinal selector. + throw new NotImplementedException("Mapping non-column selectors is not implemented."); + } + } + + return PreserveColumnOrderEnsuringUniqueNames(outerColumnsToKeep); + } + + private IReadOnlyList MapOrderingsFromSubQuery(IEnumerable innerOrderingsToKeep, SelectNode select) + { + List orderingsToKeep = new(); + + foreach (OrderByTermNode innerTerm in innerOrderingsToKeep) + { + if (innerTerm is OrderByColumnNode orderByColumn) + { + ColumnNode outerColumn = select.Columns.Single(outerColumn => outerColumn.Selector.Column == orderByColumn.Column); + var outerTerm = new OrderByColumnNode(outerColumn, innerTerm.IsAscending); + orderingsToKeep.Add(outerTerm); + } + else + { + // Rewriting stale column references from order-by-count is non-trivial, so let the post-processor handle them. + orderingsToKeep.Add(innerTerm); + } + } + + return orderingsToKeep; + } + + private TableAccessorNode GetOrCreateRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) + { + TableAccessorNode? relatedTableAccessor = _selectorsPerTable.Count == 0 + // Joining against something in an outer query. + ? CreatePrimaryTableWithIdentityCondition(leftTableAccessor.Source, relationship) + : FindRelatedTable(leftTableAccessor, relationship); + + if (relatedTableAccessor == null) + { + IReadOnlyDictionary columnMappings = _queryState.DataModelService.GetColumnMappings(relationship.RightType); + var rightTable = new TableNode(relationship.RightType, columnMappings, _queryState.TableAliasGenerator.GetNext()); + + return CreateRelatedTable(leftTableAccessor, relationship, rightTable); + } + + return relatedTableAccessor; + } + + private TableAccessorNode CreatePrimaryTableWithIdentityCondition(TableSourceNode outerTableSource, RelationshipAttribute relationship) + { + TableAccessorNode innerTableAccessor = CreatePrimaryTable(relationship.RightType); + + ComparisonNode joinCondition = CreateJoinCondition(outerTableSource, relationship, innerTableAccessor.Source); + _whereFilters.Add(joinCondition); + + return innerTableAccessor; + } + + private TableAccessorNode? FindRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) + { + Dictionary rightTableAccessors = _queryState.RelatedTables[leftTableAccessor]; + return rightTableAccessors.TryGetValue(relationship, out TableAccessorNode? rightTableAccessor) ? rightTableAccessor : null; + } + + private SelectNode ToSelect(bool isSubQuery, bool createAlias) + { + WhereNode? where = GetWhere(); + OrderByNode? orderBy = !_orderByTerms.Any() ? null : new OrderByNode(_orderByTerms); + + // Materialization using Dapper requires selectors to match property names, so adjust selector names accordingly. + Dictionary> selectorsPerTable = + isSubQuery ? _selectorsPerTable : AliasSelectorsToTableColumnNames(_selectorsPerTable); + + string? alias = createAlias ? _queryState.TableAliasGenerator.GetNext() : null; + return new SelectNode(selectorsPerTable, where, orderBy, alias); + } + + private WhereNode? GetWhere() + { + if (_whereFilters.Count == 0) + { + return null; + } + + var combinator = new LogicalCombinator(); + + FilterNode filter = _whereFilters.Count == 1 ? _whereFilters[0] : new LogicalNode(LogicalOperator.And, _whereFilters); + FilterNode collapsed = combinator.Collapse(filter); + + return new WhereNode(collapsed); + } + + private static Dictionary> AliasSelectorsToTableColumnNames( + Dictionary> selectorsPerTable) + { + Dictionary> aliasedSelectors = new(); + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectorsPerTable) + { + aliasedSelectors[tableAccessor] = tableSelectors.Select(AliasToTableColumnName).ToList(); + } + + return aliasedSelectors; + } + + private static SelectorNode AliasToTableColumnName(SelectorNode selector) + { + if (selector is ColumnSelectorNode columnSelector) + { + if (columnSelector.Column is ColumnInSelectNode columnInSelect) + { + string persistedColumnName = columnInSelect.GetPersistedColumnName(); + + if (columnInSelect.Name != persistedColumnName) + { + // t1.Id0 => t1.Id0 AS Id + return new ColumnSelectorNode(columnInSelect, persistedColumnName); + } + } + + if (columnSelector.Alias != null) + { + // t1."Id" AS Id0 => t1."Id" + return new ColumnSelectorNode(columnSelector.Column, null); + } + } + + return selector; + } + + public override SqlTreeNode DefaultVisit(QueryExpression expression, TableAccessorNode tableAccessor) + { + throw new NotSupportedException($"Expressions of type '{expression.GetType().Name}' are not supported."); + } + + public override SqlTreeNode VisitComparison(ComparisonExpression expression, TableAccessorNode tableAccessor) + { + SqlValueNode left = VisitComparisonTerm(expression.Left, tableAccessor); + SqlValueNode right = VisitComparisonTerm(expression.Right, tableAccessor); + + return new ComparisonNode(expression.Operator, left, right); + } + + private SqlValueNode VisitComparisonTerm(QueryExpression comparisonTerm, TableAccessorNode tableAccessor) + { + if (comparisonTerm is NullConstantExpression) + { + return NullConstantNode.Instance; + } + + SqlTreeNode treeNode = Visit(comparisonTerm, tableAccessor); + + if (treeNode is JoinNode join) + { + return join.InnerColumn; + } + + return (SqlValueNode)treeNode; + } + + public override SqlTreeNode VisitResourceFieldChain(ResourceFieldChainExpression expression, TableAccessorNode tableAccessor) + { + TableAccessorNode currentAccessor = tableAccessor; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is RelationshipAttribute relationship) + { + currentAccessor = GetOrCreateRelatedTable(currentAccessor, relationship); + } + else if (field is AttrAttribute attribute) + { + ColumnNode? column = currentAccessor.Source.FindColumn(attribute.Property.Name, ColumnType.Scalar, currentAccessor.Source.Alias); + + if (column == null) + { + // Unmapped columns cannot be translated to SQL. + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Sorting or filtering on the requested attribute is unavailable.", + Detail = $"Sorting or filtering on attribute '{attribute.PublicName}' is unavailable because it is unmapped." + }); + } + + return column; + } + } + + return currentAccessor; + } + + public override SqlTreeNode VisitLiteralConstant(LiteralConstantExpression expression, TableAccessorNode tableAccessor) + { + return _queryState.ParameterGenerator.Create(expression.TypedValue); + } + + public override SqlTreeNode VisitLogical(LogicalExpression expression, TableAccessorNode tableAccessor) + { + FilterNode[] terms = VisitSequence(expression.Terms, tableAccessor).ToArray(); + return new LogicalNode(expression.Operator, terms); + } + + private IEnumerable VisitSequence(IEnumerable source, TableAccessorNode tableAccessor) + where TIn : QueryExpression + where TOut : SqlTreeNode + { + return source.Select(expression => (TOut)Visit(expression, tableAccessor)).ToList(); + } + + public override SqlTreeNode VisitNot(NotExpression expression, TableAccessorNode tableAccessor) + { + var child = (FilterNode)Visit(expression.Child, tableAccessor); + FilterNode filter = child is NotNode notChild ? notChild.Child : new NotNode(child); + + var finder = new NullableAttributeFinder(_queryState.DataModelService); + finder.Visit(expression, null); + + if (finder.AttributesToNullCheck.Any()) + { + var orTerms = new List + { + filter + }; + + foreach (ResourceFieldChainExpression fieldChain in finder.AttributesToNullCheck) + { + var column = (ColumnInTableNode)Visit(fieldChain, tableAccessor); + var isNullCheck = new ComparisonNode(ComparisonOperator.Equals, column, NullConstantNode.Instance); + orTerms.Add(isNullCheck); + } + + return new LogicalNode(LogicalOperator.Or, orTerms); + } + + return filter; + } + + public override SqlTreeNode VisitHas(HasExpression expression, TableAccessorNode tableAccessor) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState) + { + _selectShape = SelectShape.One + }; + + return subSelectBuilder.GetExistsClause(expression, tableAccessor); + } + + private ExistsNode GetExistsClause(HasExpression expression, TableAccessorNode outerTableAccessor) + { + var rightTableAccessor = (TableAccessorNode)Visit(expression.TargetCollection, outerTableAccessor); + + if (expression.Filter != null) + { + var filter = (FilterNode)Visit(expression.Filter, rightTableAccessor); + _whereFilters.Add(filter); + } + + SelectNode select = ToSelect(true, false); + return new ExistsNode(select); + } + + public override SqlTreeNode VisitIsType(IsTypeExpression expression, TableAccessorNode tableAccessor) + { + throw new NotSupportedException("Resource inheritance is not supported."); + } + + public override SqlTreeNode VisitSortElement(SortElementExpression expression, TableAccessorNode tableAccessor) + { + if (expression.Target is CountExpression count) + { + var newCount = (CountNode)Visit(count, tableAccessor); + return new OrderByCountNode(newCount, expression.IsAscending); + } + + if (expression.Target is ResourceFieldChainExpression fieldChain) + { + var column = (ColumnNode)Visit(fieldChain, tableAccessor); + return new OrderByColumnNode(column, expression.IsAscending); + } + + throw new NotSupportedException($"Unsupported sort type '{expression.Target.GetType().Name}' with value '{expression.Target}'."); + } + + public override SqlTreeNode VisitSort(SortExpression expression, TableAccessorNode tableAccessor) + { + OrderByTermNode[] terms = VisitSequence(expression.Elements, tableAccessor).ToArray(); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitCount(CountExpression expression, TableAccessorNode tableAccessor) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState) + { + _selectShape = SelectShape.Count + }; + + return subSelectBuilder.GetCountClause(expression, tableAccessor); + } + + private CountNode GetCountClause(CountExpression expression, TableAccessorNode outerTableAccessor) + { + _ = Visit(expression.TargetCollection, outerTableAccessor); + + SelectNode select = ToSelect(true, false); + return new CountNode(select); + } + + public override SqlTreeNode VisitMatchText(MatchTextExpression expression, TableAccessorNode tableAccessor) + { + var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + return new LikeNode(column, expression.MatchKind, (string)expression.TextValue.TypedValue); + } + + public override SqlTreeNode VisitAny(AnyExpression expression, TableAccessorNode tableAccessor) + { + var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + + ParameterNode[] parameters = + VisitSequence(expression.Constants.OrderBy(constant => constant.TypedValue), tableAccessor).ToArray(); + + return parameters.Length == 1 ? new ComparisonNode(ComparisonOperator.Equals, column, parameters[0]) : new InNode(column, parameters); + } + + private sealed class NullableAttributeFinder : QueryExpressionRewriter + { + private readonly IDataModelService _dataModelService; + + public IList AttributesToNullCheck { get; } = new List(); + + public NullableAttributeFinder(IDataModelService dataModelService) + { + ArgumentGuard.NotNull(dataModelService); + + _dataModelService = dataModelService; + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + bool seenOptionalToOneRelationship = false; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (foreignKey.IsNullable) + { + seenOptionalToOneRelationship = true; + } + } + else if (field is AttrAttribute attribute) + { + if (seenOptionalToOneRelationship || _dataModelService.IsColumnNullable(attribute)) + { + AttributesToNullCheck.Add(expression); + } + } + } + + return base.VisitResourceFieldChain(expression, argument); + } + } + + private sealed class QueryState + { + // Provides access to the underlying data model (tables, columns and foreign keys). + public IDataModelService DataModelService { get; } + + // Used to generate unique aliases for tables. + public TableAliasGenerator TableAliasGenerator { get; } + + // Used to generate unique parameters for constants (to improve query plan caching and guard against SQL injection). + public ParameterGenerator ParameterGenerator { get; } + + public ILoggerFactory LoggerFactory { get; } + + // Prevents importing a table multiple times and enables to reference a table imported by an inner/outer query. + // In case of sub-queries, this may include temporary tables that won't survive in the final query. + public Dictionary> RelatedTables { get; } = new(); + + // In case of sub-queries, we track old/new table aliases, so we can rewrite stale references afterwards. + // This cannot be done in the moment itself, because references to tables are on method call stacks. + public Dictionary OldToNewTableAliasMap { get; } = new(); + + public QueryState(IDataModelService dataModelService, TableAliasGenerator tableAliasGenerator, ParameterGenerator parameterGenerator, + ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(dataModelService); + ArgumentGuard.NotNull(tableAliasGenerator); + ArgumentGuard.NotNull(parameterGenerator); + ArgumentGuard.NotNull(loggerFactory); + + DataModelService = dataModelService; + TableAliasGenerator = tableAliasGenerator; + ParameterGenerator = parameterGenerator; + LoggerFactory = loggerFactory; + } + + public void Reset() + { + TableAliasGenerator.Reset(); + ParameterGenerator.Reset(); + + RelatedTables.Clear(); + OldToNewTableAliasMap.Clear(); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs new file mode 100644 index 0000000000..f8a3412b36 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs @@ -0,0 +1,505 @@ +using System.Text; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Converts s into SQL text. +/// +internal sealed class SqlQueryBuilder : SqlTreeNodeVisitor +{ + private static readonly char[] SpecialCharactersInLikeDefault = + { + '\\', + '%', + '_' + }; + + private static readonly char[] SpecialCharactersInLikeSqlServer = + { + '\\', + '%', + '_', + '[', + ']' + }; + + private readonly DatabaseProvider _databaseProvider; + private readonly Dictionary _parametersByName = new(); + private int _indentDepth; + + private char[] SpecialCharactersInLike => + _databaseProvider == DatabaseProvider.SqlServer ? SpecialCharactersInLikeSqlServer : SpecialCharactersInLikeDefault; + + public IDictionary Parameters => _parametersByName.Values.ToDictionary(parameter => parameter.Name, parameter => parameter.Value); + + public SqlQueryBuilder(DatabaseProvider databaseProvider) + { + _databaseProvider = databaseProvider; + } + + public string GetCommand(SqlTreeNode node) + { + ArgumentGuard.NotNull(node); + + ResetState(); + + var builder = new StringBuilder(); + Visit(node, builder); + return builder.ToString(); + } + + private void ResetState() + { + _parametersByName.Clear(); + _indentDepth = 0; + } + + public override object? VisitSelect(SelectNode node, StringBuilder builder) + { + if (builder.Length > 0) + { + using (Indent()) + { + builder.Append('('); + WriteSelect(node, builder); + } + + AppendOnNewLine(")", builder); + } + else + { + WriteSelect(node, builder); + } + + WriteDeclareAlias(node.Alias, builder); + return null; + } + + private void WriteSelect(SelectNode node, StringBuilder builder) + { + AppendOnNewLine("SELECT ", builder); + + IEnumerable selectors = node.Selectors.SelectMany(selector => selector.Value); + VisitSequence(selectors, builder); + + foreach (TableAccessorNode tableAccessor in node.Selectors.Keys) + { + Visit(tableAccessor, builder); + } + + if (node.Where != null) + { + Visit(node.Where, builder); + } + + if (node.OrderBy != null) + { + Visit(node.OrderBy, builder); + } + } + + public override object? VisitInsert(InsertNode node, StringBuilder builder) + { + AppendOnNewLine("INSERT INTO ", builder); + Visit(node.Table, builder); + builder.Append(" ("); + VisitSequence(node.Assignments.Select(assignment => assignment.Column), builder); + builder.Append(')'); + + ColumnNode idColumn = node.Table.GetIdColumn(node.Table.Alias); + + if (_databaseProvider == DatabaseProvider.SqlServer) + { + AppendOnNewLine("OUTPUT INSERTED.", builder); + Visit(idColumn, builder); + } + + AppendOnNewLine("VALUES (", builder); + VisitSequence(node.Assignments.Select(assignment => assignment.Value), builder); + builder.Append(')'); + + if (_databaseProvider == DatabaseProvider.PostgreSql) + { + AppendOnNewLine("RETURNING ", builder); + Visit(idColumn, builder); + } + else if (_databaseProvider == DatabaseProvider.MySql) + { + builder.Append(';'); + ColumnAssignmentNode? idAssignment = node.Assignments.FirstOrDefault(assignment => assignment.Column == idColumn); + + if (idAssignment != null) + { + AppendOnNewLine("SELECT ", builder); + Visit(idAssignment.Value, builder); + } + else + { + AppendOnNewLine("SELECT LAST_INSERT_ID()", builder); + } + } + + return null; + } + + public override object? VisitUpdate(UpdateNode node, StringBuilder builder) + { + AppendOnNewLine("UPDATE ", builder); + Visit(node.Table, builder); + + AppendOnNewLine("SET ", builder); + VisitSequence(node.Assignments, builder); + + Visit(node.Where, builder); + return null; + } + + public override object? VisitDelete(DeleteNode node, StringBuilder builder) + { + AppendOnNewLine("DELETE FROM ", builder); + Visit(node.Table, builder); + Visit(node.Where, builder); + return null; + } + + public override object? VisitTable(TableNode node, StringBuilder builder) + { + string tableName = FormatIdentifier(node.Name); + builder.Append(tableName); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitFrom(FromNode node, StringBuilder builder) + { + AppendOnNewLine("FROM ", builder); + Visit(node.Source, builder); + return null; + } + + public override object? VisitJoin(JoinNode node, StringBuilder builder) + { + string joinTypeText = node.JoinType switch + { + JoinType.InnerJoin => "INNER JOIN ", + JoinType.LeftJoin => "LEFT JOIN ", + _ => throw new NotSupportedException($"Unknown join type '{node.JoinType}'.") + }; + + AppendOnNewLine(joinTypeText, builder); + Visit(node.Source, builder); + builder.Append(" ON "); + Visit(node.OuterColumn, builder); + builder.Append(" = "); + Visit(node.InnerColumn, builder); + return null; + } + + public override object? VisitColumnInTable(ColumnInTableNode node, StringBuilder builder) + { + WriteColumn(node, false, builder); + return null; + } + + public override object? VisitColumnInSelect(ColumnInSelectNode node, StringBuilder builder) + { + WriteColumn(node, node.IsVirtual, builder); + return null; + } + + private void WriteColumn(ColumnNode column, bool isVirtualColumn, StringBuilder builder) + { + WriteReferenceAlias(column.TableAlias, builder); + + string name = isVirtualColumn ? column.Name : FormatIdentifier(column.Name); + builder.Append(name); + } + + public override object? VisitColumnSelector(ColumnSelectorNode node, StringBuilder builder) + { + Visit(node.Column, builder); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitOneSelector(OneSelectorNode node, StringBuilder builder) + { + builder.Append('1'); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitCountSelector(CountSelectorNode node, StringBuilder builder) + { + builder.Append("COUNT(*)"); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitWhere(WhereNode node, StringBuilder builder) + { + AppendOnNewLine("WHERE ", builder); + Visit(node.Filter, builder); + return null; + } + + public override object? VisitNot(NotNode node, StringBuilder builder) + { + builder.Append("NOT ("); + Visit(node.Child, builder); + builder.Append(')'); + return null; + } + + public override object? VisitLogical(LogicalNode node, StringBuilder builder) + { + string operatorText = node.Operator switch + { + LogicalOperator.And => "AND", + LogicalOperator.Or => "OR", + _ => throw new NotSupportedException($"Unknown logical operator '{node.Operator}'.") + }; + + builder.Append('('); + Visit(node.Terms[0], builder); + builder.Append(')'); + + foreach (FilterNode nextTerm in node.Terms.Skip(1)) + { + builder.Append($" {operatorText} ("); + Visit(nextTerm, builder); + builder.Append(')'); + } + + return null; + } + + public override object? VisitComparison(ComparisonNode node, StringBuilder builder) + { + string operatorText = node.Operator switch + { + ComparisonOperator.Equals => node.Left is NullConstantNode || node.Right is NullConstantNode ? "IS" : "=", + ComparisonOperator.GreaterThan => ">", + ComparisonOperator.GreaterOrEqual => ">=", + ComparisonOperator.LessThan => "<", + ComparisonOperator.LessOrEqual => "<=", + _ => throw new NotSupportedException($"Unknown comparison operator '{node.Operator}'.") + }; + + Visit(node.Left, builder); + builder.Append($" {operatorText} "); + Visit(node.Right, builder); + return null; + } + + public override object? VisitLike(LikeNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" LIKE '"); + + if (node.MatchKind is TextMatchKind.Contains or TextMatchKind.EndsWith) + { + builder.Append('%'); + } + + string safeValue = node.Text.Replace("'", "''"); + bool requireEscapeClause = node.Text.IndexOfAny(SpecialCharactersInLike) != -1; + + if (requireEscapeClause) + { + foreach (char specialCharacter in SpecialCharactersInLike) + { + safeValue = safeValue.Replace(specialCharacter.ToString(), @"\" + specialCharacter); + } + } + + if (requireEscapeClause && _databaseProvider == DatabaseProvider.MySql) + { + safeValue = safeValue.Replace(@"\\", @"\\\\"); + } + + builder.Append(safeValue); + + if (node.MatchKind is TextMatchKind.Contains or TextMatchKind.StartsWith) + { + builder.Append('%'); + } + + builder.Append('\''); + + if (requireEscapeClause) + { + builder.Append(_databaseProvider == DatabaseProvider.MySql ? @" ESCAPE '\\'" : @" ESCAPE '\'"); + } + + return null; + } + + public override object? VisitIn(InNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" IN ("); + VisitSequence(node.Values, builder); + builder.Append(')'); + return null; + } + + public override object? VisitExists(ExistsNode node, StringBuilder builder) + { + builder.Append("EXISTS "); + Visit(node.SubSelect, builder); + return null; + } + + public override object? VisitCount(CountNode node, StringBuilder builder) + { + Visit(node.SubSelect, builder); + return null; + } + + public override object? VisitOrderBy(OrderByNode node, StringBuilder builder) + { + AppendOnNewLine("ORDER BY ", builder); + VisitSequence(node.Terms, builder); + return null; + } + + public override object? VisitOrderByColumn(OrderByColumnNode node, StringBuilder builder) + { + Visit(node.Column, builder); + + if (!node.IsAscending) + { + builder.Append(" DESC"); + } + + return null; + } + + public override object? VisitOrderByCount(OrderByCountNode node, StringBuilder builder) + { + Visit(node.Count, builder); + + if (!node.IsAscending) + { + builder.Append(" DESC"); + } + + return null; + } + + public override object? VisitColumnAssignment(ColumnAssignmentNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" = "); + Visit(node.Value, builder); + return null; + } + + public override object? VisitParameter(ParameterNode node, StringBuilder builder) + { + _parametersByName[node.Name] = node; + + builder.Append(node.Name); + return null; + } + + public override object? VisitNullConstant(NullConstantNode node, StringBuilder builder) + { + builder.Append("NULL"); + return null; + } + + private static void WriteDeclareAlias(string? alias, StringBuilder builder) + { + if (alias != null) + { + builder.Append($" AS {alias}"); + } + } + + private static void WriteReferenceAlias(string? alias, StringBuilder builder) + { + if (alias != null) + { + builder.Append($"{alias}."); + } + } + + private void VisitSequence(IEnumerable elements, StringBuilder builder) + where T : SqlTreeNode + { + bool isFirstElement = true; + + foreach (T element in elements) + { + if (isFirstElement) + { + isFirstElement = false; + } + else + { + builder.Append(", "); + } + + Visit(element, builder); + } + } + + private void AppendOnNewLine(string? value, StringBuilder builder) + { + if (!string.IsNullOrEmpty(value)) + { + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.Append(new string(' ', _indentDepth * 4)); + builder.Append(value); + } + } + + private string FormatIdentifier(string value) + { + return FormatIdentifier(value, _databaseProvider); + } + + internal static string FormatIdentifier(string value, DatabaseProvider databaseProvider) + { + return databaseProvider switch + { + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html + DatabaseProvider.PostgreSql => $"\"{value.Replace("\"", "\"\"")}\"", + // https://dev.mysql.com/doc/refman/8.0/en/identifiers.html + DatabaseProvider.MySql => $"`{value.Replace("`", "``")}`", + // https://learn.microsoft.com/en-us/sql/t-sql/functions/quotename-transact-sql?view=sql-server-ver16 + DatabaseProvider.SqlServer => $"[{value.Replace("]", "]]")}]", + _ => throw new NotSupportedException($"Unknown database provider '{databaseProvider}'.") + }; + } + + private IDisposable Indent() + { + _indentDepth++; + return new RevertIndentOnDispose(this); + } + + private sealed class RevertIndentOnDispose : IDisposable + { + private readonly SqlQueryBuilder _owner; + + public RevertIndentOnDispose(SqlQueryBuilder owner) + { + _owner = owner; + } + + public void Dispose() + { + _owner._indentDepth--; + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs new file mode 100644 index 0000000000..06ebc1867f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs @@ -0,0 +1,33 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.Generators; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.Builders; + +internal abstract class StatementBuilder +{ + private readonly IDataModelService _dataModelService; + + protected ParameterGenerator ParameterGenerator { get; } = new(); + + protected StatementBuilder(IDataModelService dataModelService) + { + ArgumentGuard.NotNull(dataModelService); + + _dataModelService = dataModelService; + } + + protected void ResetState() + { + ParameterGenerator.Reset(); + } + + protected TableNode GetTable(ResourceType resourceType, string? alias) + { + IReadOnlyDictionary columnMappings = _dataModelService.GetColumnMappings(resourceType); + return new TableNode(resourceType, columnMappings, alias); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs new file mode 100644 index 0000000000..152e14991c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs @@ -0,0 +1,47 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class UpdateClearOneToOneStatementBuilder : StatementBuilder +{ + public UpdateClearOneToOneStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public UpdateNode Build(ResourceType resourceType, string setColumnName, string whereColumnName, object? whereValue) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(setColumnName); + ArgumentGuard.NotNull(whereColumnName); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode setColumn = table.GetColumn(setColumnName, null, table.Alias); + ColumnAssignmentNode columnAssignment = GetColumnAssignment(setColumn); + + ColumnNode whereColumn = table.GetColumn(whereColumnName, null, table.Alias); + WhereNode where = GetWhere(whereColumn, whereValue); + + return new UpdateNode(table, columnAssignment.AsList(), where); + } + + private WhereNode GetWhere(ColumnNode column, object? value) + { + ParameterNode whereParameter = ParameterGenerator.Create(value); + var filter = new ComparisonNode(ComparisonOperator.Equals, column, whereParameter); + return new WhereNode(filter); + } + + private ColumnAssignmentNode GetColumnAssignment(ColumnNode setColumn) + { + ParameterNode parameter = ParameterGenerator.Create(null); + return new ColumnAssignmentNode(setColumn, parameter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs new file mode 100644 index 0000000000..ad4514dca6 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs @@ -0,0 +1,55 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class UpdateResourceStatementBuilder : StatementBuilder +{ + public UpdateResourceStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public UpdateNode Build(ResourceType resourceType, IReadOnlyDictionary columnsToUpdate, params object[] idValues) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNullNorEmpty(columnsToUpdate); + ArgumentGuard.NotNullNorEmpty(idValues); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + List assignments = GetColumnAssignments(columnsToUpdate, table); + + ColumnNode idColumn = table.GetIdColumn(table.Alias); + WhereNode where = GetWhere(idColumn, idValues); + + return new UpdateNode(table, assignments, where); + } + + private List GetColumnAssignments(IReadOnlyDictionary columnsToUpdate, TableNode table) + { + List assignments = new(); + + foreach ((string columnName, object? columnValue) in columnsToUpdate) + { + ColumnNode column = table.GetColumn(columnName, null, table.Alias); + ParameterNode parameter = ParameterGenerator.Create(columnValue); + + var assignment = new ColumnAssignmentNode(column, parameter); + assignments.Add(assignment); + } + + return assignments; + } + + private WhereNode GetWhere(ColumnNode idColumn, IEnumerable idValues) + { + List parameters = idValues.Select(idValue => ParameterGenerator.Create(idValue)).ToList(); + FilterNode filter = parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, idColumn, parameters[0]) : new InNode(idColumn, parameters); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs new file mode 100644 index 0000000000..607d8dc080 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs @@ -0,0 +1,175 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Data.Common; +using System.Reflection; +using Dapper; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Database-agnostic base type that infers additional information, based on foreign keys (provided by derived type) and the JSON:API resource graph. +/// +public abstract class BaseDataModelService : IDataModelService +{ + private readonly Dictionary> _columnMappingsByType = new(); + + protected IResourceGraph ResourceGraph { get; } + + public abstract DatabaseProvider DatabaseProvider { get; } + + protected BaseDataModelService(IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph); + + ResourceGraph = resourceGraph; + } + + public abstract DbConnection CreateConnection(); + + public abstract RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship); + + protected void Initialize() + { + ScanColumnMappings(); + + if (DatabaseProvider == DatabaseProvider.MySql) + { + // https://stackoverflow.com/questions/12510299/get-datetime-as-utc-with-dapper + SqlMapper.AddTypeHandler(new DapperDateTimeOffsetHandlerForMySql()); + } + } + + private void ScanColumnMappings() + { + foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes()) + { + _columnMappingsByType[resourceType] = ScanColumnMappings(resourceType); + } + } + + private IReadOnlyDictionary ScanColumnMappings(ResourceType resourceType) + { + Dictionary mappings = new(); + + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + if (!IsMapped(property)) + { + continue; + } + + string columnName = property.Name; + ResourceFieldAttribute? field = null; + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(property.Name); + + if (relationship != null) + { + RelationshipForeignKey foreignKey = GetForeignKey(relationship); + + if (!foreignKey.IsAtLeftSide) + { + continue; + } + + field = relationship; + columnName = foreignKey.ColumnName; + } + else + { + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(property.Name); + + if (attribute != null) + { + field = attribute; + } + } + + mappings[columnName] = field; + } + + return mappings; + } + + private static bool IsMapped(PropertyInfo property) + { + return property.GetCustomAttribute() == null; + } + + public IReadOnlyDictionary GetColumnMappings(ResourceType resourceType) + { + if (_columnMappingsByType.TryGetValue(resourceType, out IReadOnlyDictionary? columnMappings)) + { + return columnMappings; + } + + throw new InvalidOperationException($"Column mappings for resource type '{resourceType.ClrType.Name}' are unavailable."); + } + + public object? GetColumnValue(ResourceType resourceType, IIdentifiable resource, string columnName) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(resource); + AssertSameType(resourceType, resource); + ArgumentGuard.NotNullNorEmpty(columnName); + + IReadOnlyDictionary columnMappings = GetColumnMappings(resourceType); + + if (!columnMappings.TryGetValue(columnName, out ResourceFieldAttribute? field)) + { + throw new InvalidOperationException($"Column '{columnName}' not found on resource type '{resourceType}'."); + } + + if (field is AttrAttribute attribute) + { + return attribute.GetValue(resource); + } + + if (field is RelationshipAttribute relationship) + { + var rightResource = (IIdentifiable?)relationship.GetValue(resource); + + if (rightResource == null) + { + return null; + } + + PropertyInfo rightKeyProperty = rightResource.GetType().GetProperty(TableSourceNode.IdColumnName)!; + return rightKeyProperty.GetValue(rightResource); + } + + PropertyInfo property = resourceType.ClrType.GetProperty(columnName)!; + return property.GetValue(resource); + } + + private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) + { + Type declaredType = resourceType.ClrType; + Type instanceType = resource.GetType(); + + if (instanceType != declaredType) + { + throw new ArgumentException($"Expected resource of type '{declaredType.Name}' instead of '{instanceType.Name}'.", nameof(resource)); + } + } + + public abstract bool IsColumnNullable(AttrAttribute attribute); + + private sealed class DapperDateTimeOffsetHandlerForMySql : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, DateTimeOffset value) + { + parameter.Value = value; + } + + public override DateTimeOffset Parse(object value) + { + return DateTime.SpecifyKind((DateTime)value, DateTimeKind.Utc); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs new file mode 100644 index 0000000000..81f2778a14 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs @@ -0,0 +1,145 @@ +using System.Data.Common; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using MySqlConnector; +using Npgsql; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Derives foreign keys and connection strings from an existing Entity Framework Core model. +/// +public sealed class FromEntitiesDataModelService : BaseDataModelService +{ + private readonly Dictionary _foreignKeysByRelationship = new(); + private readonly Dictionary _columnNullabilityPerAttribute = new(); + private string? _connectionString; + private DatabaseProvider? _databaseProvider; + + public override DatabaseProvider DatabaseProvider => AssertHasDatabaseProvider(); + + public FromEntitiesDataModelService(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public void Initialize(DbContext dbContext) + { + _connectionString = dbContext.Database.GetConnectionString(); + + _databaseProvider = dbContext.Database.ProviderName switch + { + "Npgsql.EntityFrameworkCore.PostgreSQL" => DatabaseProvider.PostgreSql, + "Pomelo.EntityFrameworkCore.MySql" => DatabaseProvider.MySql, + "Microsoft.EntityFrameworkCore.SqlServer" => DatabaseProvider.SqlServer, + _ => throw new NotSupportedException($"Unknown database provider '{dbContext.Database.ProviderName}'.") + }; + + ScanForeignKeys(dbContext.Model); + ScanColumnNullability(dbContext.Model); + Initialize(); + } + + private void ScanForeignKeys(IModel entityModel) + { + foreach (RelationshipAttribute relationship in ResourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships)) + { + IEntityType? leftEntityType = entityModel.FindEntityType(relationship.LeftType.ClrType); + INavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name); + + if (navigation != null) + { + bool isAtLeftSide = navigation.ForeignKey.DeclaringEntityType.ClrType == relationship.LeftType.ClrType; + string columnName = navigation.ForeignKey.Properties.Single().Name; + bool isNullable = !navigation.ForeignKey.IsRequired; + + var foreignKey = new RelationshipForeignKey(DatabaseProvider, relationship, isAtLeftSide, columnName, isNullable); + _foreignKeysByRelationship[relationship] = foreignKey; + } + } + } + + private void ScanColumnNullability(IModel entityModel) + { + foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes()) + { + ScanColumnNullability(resourceType, entityModel); + } + } + + private void ScanColumnNullability(ResourceType resourceType, IModel entityModel) + { + IEntityType? entityType = entityModel.FindEntityType(resourceType.ClrType); + + if (entityType != null) + { + foreach (AttrAttribute attribute in resourceType.Attributes) + { + IProperty? property = entityType.FindProperty(attribute.Property.Name); + + if (property != null) + { + _columnNullabilityPerAttribute[attribute] = property.IsNullable; + } + } + } + } + + public override DbConnection CreateConnection() + { + string connectionString = AssertHasConnectionString(); + DatabaseProvider databaseProvider = AssertHasDatabaseProvider(); + + return databaseProvider switch + { + DatabaseProvider.PostgreSql => new NpgsqlConnection(connectionString), + DatabaseProvider.MySql => new MySqlConnection(connectionString), + DatabaseProvider.SqlServer => new SqlConnection(connectionString), + _ => throw new NotSupportedException($"Unknown database provider '{databaseProvider}'.") + }; + } + + public override RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship) + { + if (_foreignKeysByRelationship.TryGetValue(relationship, out RelationshipForeignKey? foreignKey)) + { + return foreignKey; + } + + throw new InvalidOperationException( + $"Foreign key mapping for relationship '{relationship.LeftType.ClrType.Name}.{relationship.Property.Name}' is unavailable."); + } + + public override bool IsColumnNullable(AttrAttribute attribute) + { + if (_columnNullabilityPerAttribute.TryGetValue(attribute, out bool isNullable)) + { + return isNullable; + } + + throw new InvalidOperationException($"Attribute '{attribute}' is unavailable."); + } + + private DatabaseProvider AssertHasDatabaseProvider() + { + if (_databaseProvider == null) + { + throw new InvalidOperationException($"Database provider is unavailable. Call {nameof(Initialize)} first."); + } + + return _databaseProvider.Value; + } + + private string AssertHasConnectionString() + { + if (_connectionString == null) + { + throw new InvalidOperationException($"Connection string is unavailable. Call {nameof(Initialize)} first."); + } + + return _connectionString; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs new file mode 100644 index 0000000000..9862c6e28f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs @@ -0,0 +1,24 @@ +using System.Data.Common; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Provides information about the underlying database model, such as foreign key and column names. +/// +public interface IDataModelService +{ + DatabaseProvider DatabaseProvider { get; } + + DbConnection CreateConnection(); + + RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship); + + IReadOnlyDictionary GetColumnMappings(ResourceType resourceType); + + object? GetColumnValue(ResourceType resourceType, IIdentifiable resource, string columnName); + + bool IsColumnNullable(AttrAttribute attribute); +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs new file mode 100644 index 0000000000..6f5572001a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs @@ -0,0 +1,69 @@ +using System.Text; +using DapperExample.TranslationToSql.Builders; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Defines foreign key information for a , which is required to produce SQL queries. +/// +[PublicAPI] +public sealed class RelationshipForeignKey +{ + private readonly DatabaseProvider _databaseProvider; + + /// + /// The JSON:API relationship mapped to this foreign key. + /// + public RelationshipAttribute Relationship { get; } + + /// + /// Indicates whether the foreign key column is defined at the left side of the JSON:API relationship. + /// + public bool IsAtLeftSide { get; } + + /// + /// The foreign key column name. + /// + public string ColumnName { get; } + + /// + /// Indicates whether the foreign key column is nullable. + /// + public bool IsNullable { get; } + + public RelationshipForeignKey(DatabaseProvider databaseProvider, RelationshipAttribute relationship, bool isAtLeftSide, string columnName, bool isNullable) + { + ArgumentGuard.NotNull(relationship); + ArgumentGuard.NotNullNorEmpty(columnName); + + _databaseProvider = databaseProvider; + Relationship = relationship; + IsAtLeftSide = isAtLeftSide; + ColumnName = columnName; + IsNullable = isNullable; + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append($"{Relationship.LeftType.ClrType.Name}.{Relationship.Property.Name} => "); + + ResourceType tableType = IsAtLeftSide ? Relationship.LeftType : Relationship.RightType; + + builder.Append(SqlQueryBuilder.FormatIdentifier(tableType.ClrType.Name.Pluralize(), _databaseProvider)); + builder.Append('.'); + builder.Append(SqlQueryBuilder.FormatIdentifier(ColumnName, _databaseProvider)); + + if (IsNullable) + { + builder.Append('?'); + } + + return builder.ToString(); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs new file mode 100644 index 0000000000..bd4df111fc --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs @@ -0,0 +1,30 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Generators; + +/// +/// Generates a SQL parameter with a unique name. +/// +internal sealed class ParameterGenerator +{ + private readonly ParameterNameGenerator _nameGenerator = new(); + + public ParameterNode Create(object? value) + { + string name = _nameGenerator.GetNext(); + return new ParameterNode(name, value); + } + + public void Reset() + { + _nameGenerator.Reset(); + } + + private sealed class ParameterNameGenerator : UniqueNameGenerator + { + public ParameterNameGenerator() + : base("@p") + { + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs new file mode 100644 index 0000000000..39d5d9d702 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs @@ -0,0 +1,12 @@ +namespace DapperExample.TranslationToSql.Generators; + +/// +/// Generates a SQL table alias with a unique name. +/// +internal sealed class TableAliasGenerator : UniqueNameGenerator +{ + public TableAliasGenerator() + : base("t") + { + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs new file mode 100644 index 0000000000..3ea42ab529 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Generators; + +internal abstract class UniqueNameGenerator +{ + private readonly string _prefix; + private int _lastIndex; + + protected UniqueNameGenerator(string prefix) + { + ArgumentGuard.NotNullNorEmpty(prefix); + + _prefix = prefix; + } + + public string GetNext() + { + return $"{_prefix}{++_lastIndex}"; + } + + public void Reset() + { + _lastIndex = 0; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs new file mode 100644 index 0000000000..9d4053b5a4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs @@ -0,0 +1,67 @@ +using System.Text; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql; + +/// +/// Converts a SQL parameter into human-readable text. Used for diagnostic purposes. +/// +internal sealed class ParameterFormatter +{ + private static readonly HashSet NumericTypes = new[] + { + typeof(bool), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(short), + typeof(ushort), + typeof(sbyte), + typeof(float), + typeof(double), + typeof(decimal) + }.ToHashSet(); + + public string Format(string parameterName, object? parameterValue) + { + StringBuilder builder = new(); + builder.Append($"{parameterName} = "); + WriteValue(parameterValue, builder); + return builder.ToString(); + } + + private void WriteValue(object? parameterValue, StringBuilder builder) + { + if (parameterValue == null) + { + builder.Append("null"); + } + else if (parameterValue is char) + { + builder.Append($"'{parameterValue}'"); + } + else if (parameterValue is byte byteValue) + { + builder.Append($"0x{byteValue:X2}"); + } + else if (parameterValue is Enum) + { + builder.Append($"{parameterValue.GetType().Name}.{parameterValue}"); + } + else + { + string value = (string)RuntimeTypeConverter.ConvertType(parameterValue, typeof(string))!; + + if (NumericTypes.Contains(parameterValue.GetType())) + { + builder.Append(value); + } + else + { + string escapedValue = value.Replace("'", "''"); + builder.Append($"'{escapedValue}'"); + } + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs b/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs new file mode 100644 index 0000000000..37ed2d3ea5 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs @@ -0,0 +1,23 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql; + +/// +/// Represents a parameterized SQL query. +/// +[PublicAPI] +public sealed class SqlCommand +{ + public string Statement { get; } + public IDictionary Parameters { get; } + + internal SqlCommand(string statement, IDictionary parameters) + { + ArgumentGuard.NotNull(statement); + ArgumentGuard.NotNull(parameters); + + Statement = statement; + Parameters = parameters; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs b/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs new file mode 100644 index 0000000000..24a129189d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs @@ -0,0 +1,151 @@ +using DapperExample.TranslationToSql.TreeNodes; +using JetBrains.Annotations; + +namespace DapperExample.TranslationToSql; + +/// +/// Implements the visitor design pattern that enables traversing a tree. +/// +[PublicAPI] +internal abstract class SqlTreeNodeVisitor +{ + public virtual TResult Visit(SqlTreeNode node, TArgument argument) + { + return node.Accept(this, argument); + } + + public virtual TResult DefaultVisit(SqlTreeNode node, TArgument argument) + { + return default!; + } + + public virtual TResult VisitSelect(SelectNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitInsert(InsertNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitUpdate(UpdateNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitDelete(DeleteNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitTable(TableNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitFrom(FromNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitJoin(JoinNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnInTable(ColumnInTableNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnInSelect(ColumnInSelectNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnSelector(ColumnSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOneSelector(OneSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitCountSelector(CountSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitWhere(WhereNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitNot(NotNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitLogical(LogicalNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitComparison(ComparisonNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitLike(LikeNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitIn(InNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitExists(ExistsNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitCount(CountNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderBy(OrderByNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderByColumn(OrderByColumnNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderByCount(OrderByCountNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnAssignment(ColumnAssignmentNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitParameter(ParameterNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitNullConstant(NullConstantNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs new file mode 100644 index 0000000000..ac447bcdc3 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs @@ -0,0 +1,163 @@ +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Collects all s in selectors that are referenced elsewhere in the query. +/// +internal sealed class ColumnSelectorUsageCollector : SqlTreeNodeVisitor +{ + private readonly HashSet _usedColumns = new(); + private readonly ILogger _logger; + + public ISet UsedColumns => _usedColumns; + + public ColumnSelectorUsageCollector(ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(loggerFactory); + + _logger = loggerFactory.CreateLogger(); + } + + public void Collect(SelectNode select) + { + ArgumentGuard.NotNull(select); + + _logger.LogDebug("Started collection of used columns."); + + _usedColumns.Clear(); + InnerVisit(select, ColumnVisitMode.Reference); + + _logger.LogDebug("Finished collection of used columns."); + } + + public override object? VisitSelect(SelectNode node, ColumnVisitMode mode) + { + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in node.Selectors) + { + InnerVisit(tableAccessor, mode); + VisitSequence(tableSelectors, ColumnVisitMode.Declaration); + } + + InnerVisit(node.Where, mode); + InnerVisit(node.OrderBy, mode); + return null; + } + + public override object? VisitFrom(FromNode node, ColumnVisitMode mode) + { + InnerVisit(node.Source, mode); + return null; + } + + public override object? VisitJoin(JoinNode node, ColumnVisitMode mode) + { + InnerVisit(node.Source, mode); + InnerVisit(node.OuterColumn, mode); + InnerVisit(node.InnerColumn, mode); + return null; + } + + public override object? VisitColumnInSelect(ColumnInSelectNode node, ColumnVisitMode mode) + { + InnerVisit(node.Selector, ColumnVisitMode.Reference); + return null; + } + + public override object? VisitColumnSelector(ColumnSelectorNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Reference) + { + _usedColumns.Add(node.Column); + _logger.LogDebug($"Added used column {node.Column}."); + } + + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitWhere(WhereNode node, ColumnVisitMode mode) + { + InnerVisit(node.Filter, mode); + return null; + } + + public override object? VisitNot(NotNode node, ColumnVisitMode mode) + { + InnerVisit(node.Child, mode); + return null; + } + + public override object? VisitLogical(LogicalNode node, ColumnVisitMode mode) + { + VisitSequence(node.Terms, mode); + return null; + } + + public override object? VisitComparison(ComparisonNode node, ColumnVisitMode mode) + { + InnerVisit(node.Left, mode); + InnerVisit(node.Right, mode); + return null; + } + + public override object? VisitLike(LikeNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitIn(InNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + VisitSequence(node.Values, mode); + return null; + } + + public override object? VisitExists(ExistsNode node, ColumnVisitMode mode) + { + InnerVisit(node.SubSelect, mode); + return null; + } + + public override object? VisitCount(CountNode node, ColumnVisitMode mode) + { + InnerVisit(node.SubSelect, mode); + return null; + } + + public override object? VisitOrderBy(OrderByNode node, ColumnVisitMode mode) + { + VisitSequence(node.Terms, mode); + return null; + } + + public override object? VisitOrderByColumn(OrderByColumnNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitOrderByCount(OrderByCountNode node, ColumnVisitMode mode) + { + InnerVisit(node.Count, mode); + return null; + } + + private void InnerVisit(SqlTreeNode? node, ColumnVisitMode mode) + { + if (node != null) + { + Visit(node, mode); + } + } + + private void VisitSequence(IEnumerable nodes, ColumnVisitMode mode) + { + foreach (SqlTreeNode node in nodes) + { + InnerVisit(node, mode); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs new file mode 100644 index 0000000000..6b0e8f8e5c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.Transformations; + +internal enum ColumnVisitMode +{ + /// + /// Definition of a column in a SQL query. + /// + Declaration, + + /// + /// Usage of a column in a SQL query. + /// + Reference +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs new file mode 100644 index 0000000000..0fcd047a3d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs @@ -0,0 +1,58 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Collapses nested logical filters. This turns "A AND (B AND C)" into "A AND B AND C". +/// +internal sealed class LogicalCombinator : SqlTreeNodeVisitor +{ + public FilterNode Collapse(FilterNode filter) + { + return TypedVisit(filter); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, object? argument) + { + var newTerms = new List(); + + foreach (FilterNode newTerm in node.Terms.Select(TypedVisit)) + { + if (newTerm is LogicalNode logicalTerm && logicalTerm.Operator == node.Operator) + { + newTerms.AddRange(logicalTerm.Terms); + } + else + { + newTerms.Add(newTerm); + } + } + + return new LogicalNode(node.Operator, newTerms); + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, object? argument) + { + return node; + } + + public override SqlTreeNode VisitNot(NotNode node, object? argument) + { + FilterNode newChild = TypedVisit(node.Child); + return new NotNode(newChild); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, object? argument) + { + SqlValueNode newLeft = TypedVisit(node.Left); + SqlValueNode newRight = TypedVisit(node.Right); + + return new ComparisonNode(node.Operator, newLeft, newRight); + } + + private T TypedVisit(T node) + where T : SqlTreeNode + { + return (T)Visit(node, null); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs new file mode 100644 index 0000000000..00bb7b0756 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs @@ -0,0 +1,307 @@ +using System.Diagnostics.CodeAnalysis; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Updates references to stale columns in sub-queries, by pulling them out until in scope. +/// +/// +///

+/// Regular query: +///

+///

+/// Equivalent with sub-query: +/// +///

+/// The reference to t1 in the WHERE clause has become stale and needs to be pulled out into scope, which is t2. +///
+internal sealed class StaleColumnReferenceRewriter : SqlTreeNodeVisitor +{ + private readonly IReadOnlyDictionary _oldToNewTableAliasMap; + private readonly ILogger _logger; + private readonly Stack> _tablesInScopeStack = new(); + + public StaleColumnReferenceRewriter(IReadOnlyDictionary oldToNewTableAliasMap, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(oldToNewTableAliasMap); + ArgumentGuard.NotNull(loggerFactory); + + _oldToNewTableAliasMap = oldToNewTableAliasMap; + _logger = loggerFactory.CreateLogger(); + } + + public SelectNode PullColumnsIntoScope(SelectNode select) + { + _tablesInScopeStack.Clear(); + + return TypedVisit(select, ColumnVisitMode.Reference); + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, ColumnVisitMode mode) + { + throw new NotSupportedException($"Nodes of type '{node.GetType().Name}' are not supported."); + } + + public override SqlTreeNode VisitSelect(SelectNode node, ColumnVisitMode mode) + { + IncludeTableAliasInCurrentScope(node); + + using IDisposable scope = EnterSelectScope(); + + IReadOnlyDictionary> selectors = VisitSelectors(node.Selectors, mode); + WhereNode? where = TypedVisit(node.Where, mode); + OrderByNode? orderBy = TypedVisit(node.OrderBy, mode); + return new SelectNode(selectors, where, orderBy, node.Alias); + } + + private void IncludeTableAliasInCurrentScope(TableSourceNode tableSource) + { + if (tableSource.Alias != null) + { + Dictionary tablesInScope = _tablesInScopeStack.Peek(); + tablesInScope.Add(tableSource.Alias, tableSource); + } + } + + private IDisposable EnterSelectScope() + { + Dictionary newScope = CopyTopStackElement(); + _tablesInScopeStack.Push(newScope); + + return new PopStackOnDispose>(_tablesInScopeStack); + } + + private Dictionary CopyTopStackElement() + { + if (_tablesInScopeStack.Count == 0) + { + return new Dictionary(); + } + + Dictionary topElement = _tablesInScopeStack.Peek(); + return new Dictionary(topElement); + } + + private IReadOnlyDictionary> VisitSelectors( + IReadOnlyDictionary> selectors, ColumnVisitMode mode) + { + Dictionary> newSelectors = new(); + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectors) + { + TableAccessorNode newTableAccessor = TypedVisit(tableAccessor, mode); + IReadOnlyList newTableSelectors = VisitList(tableSelectors, ColumnVisitMode.Declaration); + + newSelectors.Add(newTableAccessor, newTableSelectors); + } + + return newSelectors; + } + + public override SqlTreeNode VisitTable(TableNode node, ColumnVisitMode mode) + { + IncludeTableAliasInCurrentScope(node); + return node; + } + + public override SqlTreeNode VisitFrom(FromNode node, ColumnVisitMode mode) + { + TableSourceNode source = TypedVisit(node.Source, mode); + return new FromNode(source); + } + + public override SqlTreeNode VisitJoin(JoinNode node, ColumnVisitMode mode) + { + TableSourceNode source = TypedVisit(node.Source, mode); + ColumnNode outerColumn = TypedVisit(node.OuterColumn, mode); + ColumnNode innerColumn = TypedVisit(node.InnerColumn, mode); + return new JoinNode(node.JoinType, source, outerColumn, innerColumn); + } + + public override SqlTreeNode VisitColumnInTable(ColumnInTableNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Declaration) + { + return node; + } + + Dictionary tablesInScope = _tablesInScopeStack.Peek(); + return MapColumnInTable(node, tablesInScope); + } + + private ColumnNode MapColumnInTable(ColumnInTableNode column, IDictionary tablesInScope) + { + if (column.TableAlias != null && !tablesInScope.ContainsKey(column.TableAlias)) + { + // Stale column found. Keep pulling out until in scope. + string currentAlias = column.TableAlias; + + while (_oldToNewTableAliasMap.ContainsKey(currentAlias)) + { + currentAlias = _oldToNewTableAliasMap[currentAlias]; + + if (tablesInScope.TryGetValue(currentAlias, out TableSourceNode? currentTable)) + { + ColumnNode? outerColumn = currentTable.FindColumn(column.Name, null, column.TableAlias); + + if (outerColumn != null) + { + _logger.LogDebug($"Mapped inaccessible column {column} to {outerColumn}."); + return outerColumn; + } + } + } + + string candidateScopes = string.Join(", ", tablesInScope.Select(table => table.Key)); + throw new InvalidOperationException($"Failed to map inaccessible column {column} to any of the tables in scope: {candidateScopes}."); + } + + return column; + } + + public override SqlTreeNode VisitColumnInSelect(ColumnInSelectNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Declaration) + { + return node; + } + + ColumnSelectorNode selector = TypedVisit(node.Selector, mode); + return new ColumnInSelectNode(selector, node.TableAlias); + } + + public override SqlTreeNode VisitColumnSelector(ColumnSelectorNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, ColumnVisitMode.Declaration); + return new ColumnSelectorNode(column, node.Alias); + } + + public override SqlTreeNode VisitOneSelector(OneSelectorNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitCountSelector(CountSelectorNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitWhere(WhereNode node, ColumnVisitMode mode) + { + FilterNode filter = TypedVisit(node.Filter, mode); + return new WhereNode(filter); + } + + public override SqlTreeNode VisitNot(NotNode node, ColumnVisitMode mode) + { + FilterNode child = TypedVisit(node.Child, mode); + return new NotNode(child); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, ColumnVisitMode mode) + { + IReadOnlyList terms = VisitList(node.Terms, mode); + return new LogicalNode(node.Operator, terms); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, ColumnVisitMode mode) + { + SqlValueNode left = TypedVisit(node.Left, mode); + SqlValueNode right = TypedVisit(node.Right, mode); + return new ComparisonNode(node.Operator, left, right); + } + + public override SqlTreeNode VisitLike(LikeNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + return new LikeNode(column, node.MatchKind, node.Text); + } + + public override SqlTreeNode VisitIn(InNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + IReadOnlyList values = VisitList(node.Values, mode); + return new InNode(column, values); + } + + public override SqlTreeNode VisitExists(ExistsNode node, ColumnVisitMode mode) + { + SelectNode subSelect = TypedVisit(node.SubSelect, mode); + return new ExistsNode(subSelect); + } + + public override SqlTreeNode VisitCount(CountNode node, ColumnVisitMode mode) + { + SelectNode subSelect = TypedVisit(node.SubSelect, mode); + return new CountNode(subSelect); + } + + public override SqlTreeNode VisitOrderBy(OrderByNode node, ColumnVisitMode mode) + { + IReadOnlyList terms = VisitList(node.Terms, mode); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitOrderByColumn(OrderByColumnNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + return new OrderByColumnNode(column, node.IsAscending); + } + + public override SqlTreeNode VisitOrderByCount(OrderByCountNode node, ColumnVisitMode mode) + { + CountNode count = TypedVisit(node.Count, mode); + return new OrderByCountNode(count, node.IsAscending); + } + + public override SqlTreeNode VisitParameter(ParameterNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitNullConstant(NullConstantNode node, ColumnVisitMode mode) + { + return node; + } + + [return: NotNullIfNotNull("node")] + private T? TypedVisit(T? node, ColumnVisitMode mode) + where T : SqlTreeNode + { + return node != null ? (T)Visit(node, mode) : null; + } + + private IReadOnlyList VisitList(IEnumerable nodes, ColumnVisitMode mode) + where T : SqlTreeNode + { + return nodes.Select(element => TypedVisit(element, mode)).ToList(); + } + + private sealed class PopStackOnDispose : IDisposable + { + private readonly Stack _stack; + + public PopStackOnDispose(Stack stack) + { + _stack = stack; + } + + public void Dispose() + { + _stack.Pop(); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs new file mode 100644 index 0000000000..36bb3f0c0a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs @@ -0,0 +1,219 @@ +using System.Diagnostics.CodeAnalysis; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Removes unreferenced selectors in sub-queries. +/// +/// +///

+/// Regular query: +///

+///

+/// Equivalent with sub-query: +/// +///

+/// The selectors t1."AccountId" and t1."FirstName" have no references and can be removed. +///
+internal sealed class UnusedSelectorsRewriter : SqlTreeNodeVisitor, SqlTreeNode> +{ + private readonly ColumnSelectorUsageCollector _usageCollector; + private readonly ILogger _logger; + private SelectNode _rootSelect = null!; + private bool _hasChanged; + + public UnusedSelectorsRewriter(ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(loggerFactory); + + _usageCollector = new ColumnSelectorUsageCollector(loggerFactory); + _logger = loggerFactory.CreateLogger(); + } + + public SelectNode RemoveUnusedSelectorsInSubQueries(SelectNode select) + { + ArgumentGuard.NotNull(select); + + _rootSelect = select; + + do + { + _hasChanged = false; + _usageCollector.Collect(_rootSelect); + + _logger.LogDebug("Started removal of unused selectors."); + _rootSelect = TypedVisit(_rootSelect, _usageCollector.UsedColumns); + _logger.LogDebug("Finished removal of unused selectors."); + } + while (_hasChanged); + + return _rootSelect; + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, ISet usedColumns) + { + return node; + } + + public override SqlTreeNode VisitSelect(SelectNode node, ISet usedColumns) + { + IReadOnlyDictionary> selectors = VisitSelectors(node, usedColumns); + WhereNode? where = TypedVisit(node.Where, usedColumns); + OrderByNode? orderBy = TypedVisit(node.OrderBy, usedColumns); + return new SelectNode(selectors, where, orderBy, node.Alias); + } + + private IReadOnlyDictionary> VisitSelectors(SelectNode select, ISet usedColumns) + { + Dictionary> newSelectors = new(); + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in select.Selectors) + { + TableAccessorNode newTableAccessor = TypedVisit(tableAccessor, usedColumns); + IReadOnlyList newTableSelectors = select == _rootSelect ? tableSelectors : VisitTableSelectors(tableSelectors, usedColumns); + newSelectors.Add(newTableAccessor, newTableSelectors); + } + + return newSelectors; + } + + private List VisitTableSelectors(IEnumerable selectors, ISet usedColumns) + { + List newTableSelectors = new(); + + foreach (SelectorNode selector in selectors) + { + if (selector is ColumnSelectorNode columnSelector) + { + if (!usedColumns.Contains(columnSelector.Column)) + { + _logger.LogDebug($"Removing unused selector {columnSelector}."); + _hasChanged = true; + continue; + } + } + + newTableSelectors.Add(selector); + } + + return newTableSelectors; + } + + public override SqlTreeNode VisitFrom(FromNode node, ISet usedColumns) + { + TableSourceNode source = TypedVisit(node.Source, usedColumns); + return new FromNode(source); + } + + public override SqlTreeNode VisitJoin(JoinNode node, ISet usedColumns) + { + TableSourceNode source = TypedVisit(node.Source, usedColumns); + ColumnNode outerColumn = TypedVisit(node.OuterColumn, usedColumns); + ColumnNode innerColumn = TypedVisit(node.InnerColumn, usedColumns); + return new JoinNode(node.JoinType, source, outerColumn, innerColumn); + } + + public override SqlTreeNode VisitColumnInSelect(ColumnInSelectNode node, ISet usedColumns) + { + ColumnSelectorNode selector = TypedVisit(node.Selector, usedColumns); + return new ColumnInSelectNode(selector, node.TableAlias); + } + + public override SqlTreeNode VisitColumnSelector(ColumnSelectorNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new ColumnSelectorNode(column, node.Alias); + } + + public override SqlTreeNode VisitWhere(WhereNode node, ISet usedColumns) + { + FilterNode filter = TypedVisit(node.Filter, usedColumns); + return new WhereNode(filter); + } + + public override SqlTreeNode VisitNot(NotNode node, ISet usedColumns) + { + FilterNode child = TypedVisit(node.Child, usedColumns); + return new NotNode(child); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, ISet usedColumns) + { + IReadOnlyList terms = VisitList(node.Terms, usedColumns); + return new LogicalNode(node.Operator, terms); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, ISet usedColumns) + { + SqlValueNode left = TypedVisit(node.Left, usedColumns); + SqlValueNode right = TypedVisit(node.Right, usedColumns); + return new ComparisonNode(node.Operator, left, right); + } + + public override SqlTreeNode VisitLike(LikeNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new LikeNode(column, node.MatchKind, node.Text); + } + + public override SqlTreeNode VisitIn(InNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + IReadOnlyList values = VisitList(node.Values, usedColumns); + return new InNode(column, values); + } + + public override SqlTreeNode VisitExists(ExistsNode node, ISet usedColumns) + { + SelectNode subSelect = TypedVisit(node.SubSelect, usedColumns); + return new ExistsNode(subSelect); + } + + public override SqlTreeNode VisitCount(CountNode node, ISet usedColumns) + { + SelectNode subSelect = TypedVisit(node.SubSelect, usedColumns); + return new CountNode(subSelect); + } + + public override SqlTreeNode VisitOrderBy(OrderByNode node, ISet usedColumns) + { + IReadOnlyList terms = VisitList(node.Terms, usedColumns); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitOrderByColumn(OrderByColumnNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new OrderByColumnNode(column, node.IsAscending); + } + + public override SqlTreeNode VisitOrderByCount(OrderByCountNode node, ISet usedColumns) + { + CountNode count = TypedVisit(node.Count, usedColumns); + return new OrderByCountNode(count, node.IsAscending); + } + + [return: NotNullIfNotNull("node")] + private T? TypedVisit(T? node, ISet usedColumns) + where T : SqlTreeNode + { + return node != null ? (T)Visit(node, usedColumns) : null; + } + + private IReadOnlyList VisitList(IEnumerable nodes, ISet usedColumns) + where T : SqlTreeNode + { + return nodes.Select(element => TypedVisit(element, usedColumns)).ToList(); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs new file mode 100644 index 0000000000..1884dc8dbf --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents assignment to a column in an . For example, in: +/// . +/// +internal sealed class ColumnAssignmentNode : SqlTreeNode +{ + public ColumnNode Column { get; } + public SqlValueNode Value { get; } + + public ColumnAssignmentNode(ColumnNode column, SqlValueNode value) + { + ArgumentGuard.NotNull(column); + ArgumentGuard.NotNull(value); + + Column = column; + Value = value; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnAssignment(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs new file mode 100644 index 0000000000..e4b79fe7eb --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a column in a . For example, in: +/// . +/// +internal sealed class ColumnInSelectNode : ColumnNode +{ + public ColumnSelectorNode Selector { get; } + + public bool IsVirtual => Selector.Alias != null || Selector.Column is ColumnInSelectNode { IsVirtual: true }; + + public ColumnInSelectNode(ColumnSelectorNode selector, string? tableAlias) + : base(GetColumnName(selector), selector.Column.Type, tableAlias) + { + Selector = selector; + } + + private static string GetColumnName(ColumnSelectorNode selector) + { + ArgumentGuard.NotNull(selector); + + return selector.Identity; + } + + public string GetPersistedColumnName() + { + return Selector.Column is ColumnInSelectNode columnInSelect ? columnInSelect.GetPersistedColumnName() : Selector.Column.Name; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnInSelect(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs new file mode 100644 index 0000000000..8e8aab29ce --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a column in a . For example, in: +/// . +/// +internal sealed class ColumnInTableNode : ColumnNode +{ + public ColumnInTableNode(string name, ColumnType type, string? tableAlias) + : base(name, type, tableAlias) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnInTable(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs new file mode 100644 index 0000000000..e4fbcf14e6 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs @@ -0,0 +1,33 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for references to columns in s. +/// +internal abstract class ColumnNode : SqlValueNode +{ + public string Name { get; } + public ColumnType Type { get; } + public string? TableAlias { get; } + + protected ColumnNode(string name, ColumnType type, string? tableAlias) + { + ArgumentGuard.NotNullNorEmpty(name); + + Name = name; + Type = type; + TableAlias = tableAlias; + } + + public int GetTableAliasIndex() + { + if (TableAlias == null) + { + return -1; + } + + string? number = TableAlias[1..]; + return int.Parse(number); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs new file mode 100644 index 0000000000..ab2ab1031f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a column selector in a . For example, in: +/// . +/// +internal sealed class ColumnSelectorNode : SelectorNode +{ + public ColumnNode Column { get; } + + public string Identity => Alias ?? Column.Name; + + public ColumnSelectorNode(ColumnNode column, string? alias) + : base(alias) + { + ArgumentGuard.NotNull(column); + + Column = column; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs new file mode 100644 index 0000000000..47b3082225 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs @@ -0,0 +1,17 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Lists the column types used in a . +/// +internal enum ColumnType +{ + /// + /// A scalar (non-relationship) column, for example: FirstName. + /// + Scalar, + + /// + /// A foreign key column, for example: OwnerId. + /// + ForeignKey +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs new file mode 100644 index 0000000000..dbf61d5451 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the comparison of two values. For example: = @p1 +/// ]]>. +/// +internal sealed class ComparisonNode : FilterNode +{ + public ComparisonOperator Operator { get; } + public SqlValueNode Left { get; } + public SqlValueNode Right { get; } + + public ComparisonNode(ComparisonOperator @operator, SqlValueNode left, SqlValueNode right) + { + ArgumentGuard.NotNull(left); + ArgumentGuard.NotNull(right); + + Operator = @operator; + Left = left; + Right = right; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitComparison(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs new file mode 100644 index 0000000000..07182d036f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a count on the number of rows returned from a sub-query. For example, in: +/// @p1 +/// ]]>. +/// +internal sealed class CountNode : SqlValueNode +{ + public SelectNode SubSelect { get; } + + public CountNode(SelectNode subSelect) + { + ArgumentGuard.NotNull(subSelect); + + SubSelect = subSelect; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitCount(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs new file mode 100644 index 0000000000..07ad67f144 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a row count selector in a . For example, in: +/// . +/// +internal sealed class CountSelectorNode : SelectorNode +{ + public CountSelectorNode(string? alias) + : base(alias) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitCountSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs new file mode 100644 index 0000000000..aa3968f872 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a DELETE FROM clause. For example: . +/// +internal sealed class DeleteNode : SqlTreeNode +{ + public TableNode Table { get; } + public WhereNode Where { get; } + + public DeleteNode(TableNode table, WhereNode where) + { + ArgumentGuard.NotNull(table); + ArgumentGuard.NotNull(where); + + Table = table; + Where = where; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitDelete(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs new file mode 100644 index 0000000000..b73882122c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a filter on whether a sub-query contains rows. For example, in: +/// . +/// +internal sealed class ExistsNode : FilterNode +{ + public SelectNode SubSelect { get; } + + public ExistsNode(SelectNode subSelect) + { + ArgumentGuard.NotNull(subSelect); + + SubSelect = subSelect; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitExists(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs new file mode 100644 index 0000000000..1874fc16e4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs @@ -0,0 +1,8 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for filters that return a boolean value. +/// +internal abstract class FilterNode : SqlTreeNode +{ +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs new file mode 100644 index 0000000000..8ec4ab5c20 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs @@ -0,0 +1,19 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a FROM clause. For example: . +/// +internal sealed class FromNode : TableAccessorNode +{ + public FromNode(TableSourceNode source) + : base(source) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitFrom(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs new file mode 100644 index 0000000000..26d3c2ec47 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a filter that matches one value in a candidate set. For example: . +/// +internal sealed class InNode : FilterNode +{ + public ColumnNode Column { get; } + public IReadOnlyList Values { get; } + + public InNode(ColumnNode column, IReadOnlyList values) + { + ArgumentGuard.NotNull(column); + ArgumentGuard.NotNullNorEmpty(values); + + Column = column; + Values = values; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitIn(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs new file mode 100644 index 0000000000..8ed6770136 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an INSERT INTO clause. For example: . +/// +internal sealed class InsertNode : SqlTreeNode +{ + public TableNode Table { get; } + public IReadOnlyCollection Assignments { get; } + + public InsertNode(TableNode table, IReadOnlyCollection assignments) + { + ArgumentGuard.NotNull(table); + ArgumentGuard.NotNullNorEmpty(assignments); + + Table = table; + Assignments = assignments; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitInsert(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs new file mode 100644 index 0000000000..6ed2e4c73c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a JOIN clause. For example: . +/// +internal sealed class JoinNode : TableAccessorNode +{ + public JoinType JoinType { get; } + public ColumnNode OuterColumn { get; } + public ColumnNode InnerColumn { get; } + + public JoinNode(JoinType joinType, TableSourceNode source, ColumnNode outerColumn, ColumnNode innerColumn) + : base(source) + { + ArgumentGuard.NotNull(outerColumn); + ArgumentGuard.NotNull(innerColumn); + + JoinType = joinType; + OuterColumn = outerColumn; + InnerColumn = innerColumn; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitJoin(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs new file mode 100644 index 0000000000..3a3be7369d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs @@ -0,0 +1,7 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +internal enum JoinType +{ + LeftJoin, + InnerJoin +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs new file mode 100644 index 0000000000..034e5c012e --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a sub-string match filter. For example: . +/// +internal sealed class LikeNode : FilterNode +{ + public ColumnNode Column { get; } + public TextMatchKind MatchKind { get; } + public string Text { get; } + + public LikeNode(ColumnNode column, TextMatchKind matchKind, string text) + { + ArgumentGuard.NotNull(column); + ArgumentGuard.NotNull(text); + + Column = column; + MatchKind = matchKind; + Text = text; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitLike(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs new file mode 100644 index 0000000000..40fc95b88c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a logical AND/OR filter. For example: . +/// +internal sealed class LogicalNode : FilterNode +{ + public LogicalOperator Operator { get; } + public IReadOnlyList Terms { get; } + + public LogicalNode(LogicalOperator @operator, params FilterNode[] terms) + : this(@operator, terms.ToList()) + { + } + + public LogicalNode(LogicalOperator @operator, IReadOnlyList terms) + { + ArgumentGuard.NotNull(terms); + + if (terms.Count < 2) + { + throw new ArgumentException("At least two terms are required.", nameof(terms)); + } + + Operator = @operator; + Terms = terms; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitLogical(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs new file mode 100644 index 0000000000..38c5d80f26 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the logical negation of another filter. For example: . +/// +internal sealed class NotNode : FilterNode +{ + public FilterNode Child { get; } + + public NotNode(FilterNode child) + { + ArgumentGuard.NotNull(child); + + Child = child; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitNot(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs new file mode 100644 index 0000000000..8d345d2563 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs @@ -0,0 +1,18 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the value NULL. +/// +internal sealed class NullConstantNode : SqlValueNode +{ + public static readonly NullConstantNode Instance = new(); + + private NullConstantNode() + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitNullConstant(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs new file mode 100644 index 0000000000..c86aea6d63 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the ordinal selector for the first, unnamed column in a . For example, in: +/// . +/// +internal sealed class OneSelectorNode : SelectorNode +{ + public OneSelectorNode(string? alias) + : base(alias) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOneSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs new file mode 100644 index 0000000000..372b1e86ff --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents ordering on a column in an . For example, in: +/// . +/// +internal sealed class OrderByColumnNode : OrderByTermNode +{ + public ColumnNode Column { get; } + + public OrderByColumnNode(ColumnNode column, bool isAscending) + : base(isAscending) + { + ArgumentGuard.NotNull(column); + + Column = column; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderByColumn(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs new file mode 100644 index 0000000000..3d8f8c240a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents ordering on the number of rows returned from a sub-query in an . For example, +/// in: . +/// +internal sealed class OrderByCountNode : OrderByTermNode +{ + public CountNode Count { get; } + + public OrderByCountNode(CountNode count, bool isAscending) + : base(isAscending) + { + ArgumentGuard.NotNull(count); + + Count = count; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderByCount(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs new file mode 100644 index 0000000000..dc80ea4395 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an ORDER BY clause. For example: . +/// +internal sealed class OrderByNode : SqlTreeNode +{ + public IReadOnlyList Terms { get; } + + public OrderByNode(IReadOnlyList terms) + { + ArgumentGuard.NotNullNorEmpty(terms); + + Terms = terms; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderBy(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs new file mode 100644 index 0000000000..2c3fc80b3e --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for terms in an . +/// +internal abstract class OrderByTermNode : SqlTreeNode +{ + public bool IsAscending { get; } + + protected OrderByTermNode(bool isAscending) + { + IsAscending = isAscending; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs new file mode 100644 index 0000000000..c2a5824f72 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs @@ -0,0 +1,39 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the name and value of a parameter. For example: . +/// +internal sealed class ParameterNode : SqlValueNode +{ + private static readonly ParameterFormatter Formatter = new(); + + public string Name { get; } + public object? Value { get; } + + public ParameterNode(string name, object? value) + { + ArgumentGuard.NotNull(name); + + if (!name.StartsWith('@') || name.Length < 2) + { + throw new ArgumentException("Parameter name must start with an '@' symbol and not be empty.", nameof(name)); + } + + Name = name; + Value = value; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitParameter(this, argument); + } + + public override string ToString() + { + return Formatter.Format(Name, Value); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs new file mode 100644 index 0000000000..1b7b96c3f9 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs @@ -0,0 +1,70 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a SELECT clause, which is a shaped selection of rows from database tables. For example: +/// @p1 +/// ORDER BY t1.Age, t1.LastName +/// ]]>. +/// +internal sealed class SelectNode : TableSourceNode +{ + private readonly List _columns = new(); + + public IReadOnlyDictionary> Selectors { get; } + public WhereNode? Where { get; } + public OrderByNode? OrderBy { get; } + + public override IReadOnlyList Columns => _columns; + + public SelectNode(IReadOnlyDictionary> selectors, WhereNode? where, OrderByNode? orderBy, string? alias) + : base(alias) + { + ArgumentGuard.NotNullNorEmpty(selectors); + + Selectors = selectors; + Where = where; + OrderBy = orderBy; + + ReadSelectorColumns(selectors); + } + + private void ReadSelectorColumns(IReadOnlyDictionary> selectors) + { + foreach (ColumnSelectorNode columnSelector in selectors.SelectMany(selector => selector.Value).OfType()) + { + var column = new ColumnInSelectNode(columnSelector, Alias); + _columns.Add(column); + } + } + + public override ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + if (innerTableAlias == Alias) + { + return Columns.FirstOrDefault(column => column.GetPersistedColumnName() == persistedColumnName && (type == null || column.Type == type)); + } + + foreach (TableSourceNode tableSource in Selectors.Keys.Select(tableAccessor => tableAccessor.Source)) + { + ColumnNode? innerColumn = tableSource.FindColumn(persistedColumnName, type, innerTableAlias); + + if (innerColumn != null) + { + ColumnInSelectNode outerColumn = Columns.Single(column => column.Selector.Column == innerColumn); + return outerColumn; + } + } + + return null; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitSelect(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs new file mode 100644 index 0000000000..8a47a8af66 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for selectors in a . +/// +internal abstract class SelectorNode : SqlTreeNode +{ + public string? Alias { get; } + + protected SelectorNode(string? alias) + { + Alias = alias; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs new file mode 100644 index 0000000000..3b2053a963 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs @@ -0,0 +1,18 @@ +using DapperExample.TranslationToSql.Builders; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for all nodes in a SQL query. +/// +internal abstract class SqlTreeNode +{ + public abstract TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument); + + public override string ToString() + { + // This is only used for debugging purposes. + var queryBuilder = new SqlQueryBuilder(DatabaseProvider.PostgreSql); + return queryBuilder.GetCommand(this); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs new file mode 100644 index 0000000000..bacbd5672f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs @@ -0,0 +1,8 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for values, such as parameters, column references and NULL. +/// +internal abstract class SqlValueNode : SqlTreeNode +{ +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs new file mode 100644 index 0000000000..4096789919 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for accessors to tabular data, such as FROM and JOIN. +/// +internal abstract class TableAccessorNode : SqlTreeNode +{ + public TableSourceNode Source { get; } + + protected TableAccessorNode(TableSourceNode source) + { + ArgumentGuard.NotNull(source); + + Source = source; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs new file mode 100644 index 0000000000..03d8353eb5 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs @@ -0,0 +1,63 @@ +using Humanizer; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a database table. For example, in: +/// . +/// +internal sealed class TableNode : TableSourceNode +{ + private readonly ResourceType _resourceType; + private readonly IReadOnlyDictionary _columnMappings; + private readonly List _columns = new(); + + public string Name => _resourceType.ClrType.Name.Pluralize(); + + public override IReadOnlyList Columns => _columns; + + public TableNode(ResourceType resourceType, IReadOnlyDictionary columnMappings, string? alias) + : base(alias) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(columnMappings); + + _resourceType = resourceType; + _columnMappings = columnMappings; + + ReadColumnMappings(); + } + + private void ReadColumnMappings() + { + foreach ((string columnName, ResourceFieldAttribute? field) in _columnMappings) + { + ColumnType columnType = field is RelationshipAttribute ? ColumnType.ForeignKey : ColumnType.Scalar; + var column = new ColumnInTableNode(columnName, columnType, Alias); + + _columns.Add(column); + } + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitTable(this, argument); + } + + public override ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + if (innerTableAlias != Alias) + { + return null; + } + + return Columns.FirstOrDefault(column => column.Name == persistedColumnName && (type == null || column.Type == type)); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs new file mode 100644 index 0000000000..6628ed11dc --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for tabular data sources, such as database tables and sub-queries. +/// +internal abstract class TableSourceNode : SqlTreeNode +{ + public const string IdColumnName = nameof(Identifiable.Id); + + public abstract IReadOnlyList Columns { get; } + public string? Alias { get; } + + protected TableSourceNode(string? alias) + { + Alias = alias; + } + + public ColumnNode GetIdColumn(string? innerTableAlias) + { + return GetColumn(IdColumnName, ColumnType.Scalar, innerTableAlias); + } + + public ColumnNode GetColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + ColumnNode? column = FindColumn(persistedColumnName, type, innerTableAlias); + + if (column == null) + { + throw new ArgumentException($"Column '{persistedColumnName}' not found.", nameof(persistedColumnName)); + } + + return column; + } + + public abstract ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias); +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs new file mode 100644 index 0000000000..3aa5dbdf73 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an UPDATE clause. For example: . +/// +internal sealed class UpdateNode : SqlTreeNode +{ + public TableNode Table { get; } + public IReadOnlyCollection Assignments { get; } + public WhereNode Where { get; } + + public UpdateNode(TableNode table, IReadOnlyCollection assignments, WhereNode where) + { + ArgumentGuard.NotNull(table); + ArgumentGuard.NotNullNorEmpty(assignments); + ArgumentGuard.NotNull(where); + + Table = table; + Assignments = assignments; + Where = where; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitUpdate(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs new file mode 100644 index 0000000000..d8d72601c5 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a WHERE clause. For example: @p1 +/// ]]>. +/// +internal sealed class WhereNode : SqlTreeNode +{ + public FilterNode Filter { get; } + + public WhereNode(FilterNode filter) + { + ArgumentGuard.NotNull(filter); + + Filter = filter; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitWhere(this, argument); + } +} diff --git a/src/Examples/DapperExample/appsettings.json b/src/Examples/DapperExample/appsettings.json new file mode 100644 index 0000000000..b4ddb2dac9 --- /dev/null +++ b/src/Examples/DapperExample/appsettings.json @@ -0,0 +1,24 @@ +{ + "DatabaseProvider": "PostgreSql", + "ConnectionStrings": { + // docker run --rm --detach --name dapper-example-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest + // docker run --rm --detach --name dapper-example-postgresql-management --link dapper-example-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest + "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true", + // docker run --rm --detach --name dapper-example-mysql-db -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=DapperExample -e MYSQL_USER=mysql -e MYSQL_PASSWORD=mysql -p 3306:3306 mysql:latest --default-authentication-plugin=mysql_native_password + // docker run --rm --detach --name dapper-example-mysql-management --link dapper-example-mysql-db:db -p 8081:80 phpmyadmin/phpmyadmin + "DapperExampleMySql": "Host=localhost;Database=DapperExample;User ID=mysql;Password=mysql", + // docker run --rm --detach --name dapper-example-sqlserver -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Passw0rd!" -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest + "DapperExampleSqlServer": "Server=localhost;Database=DapperExample;User ID=sa;Password=Passw0rd!;TrustServerCertificate=true" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "DapperExample": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 764cc6a6c3..a65563c681 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -27,18 +27,18 @@ static WebApplication CreateWebApplication(string[] args) // Add services to the container. ConfigureServices(builder); - WebApplication webApplication = builder.Build(); + WebApplication app = builder.Build(); // Configure the HTTP request pipeline. - ConfigurePipeline(webApplication); + ConfigurePipeline(app); if (CodeTimingSessionManager.IsEnabled) { string timingResults = CodeTimingSessionManager.Current.GetResults(); - webApplication.Logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); + app.Logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); } - return webApplication; + return app; } static void ConfigureServices(WebApplicationBuilder builder) @@ -81,18 +81,18 @@ static void SetDbContextDebugOptions(DbContextOptionsBuilder options) options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } -static void ConfigurePipeline(WebApplication webApplication) +static void ConfigurePipeline(WebApplication app) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); - webApplication.UseRouting(); + app.UseRouting(); using (CodeTimingSessionManager.Current.Measure("UseJsonApi()")) { - webApplication.UseJsonApi(); + app.UseJsonApi(); } - webApplication.MapControllers(); + app.MapControllers(); } static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) diff --git a/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs index 155a48c3c2..a715457d54 100644 --- a/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("DapperExample")] [assembly: InternalsVisibleTo("Benchmarks")] [assembly: InternalsVisibleTo("JsonApiDotNetCore")] [assembly: InternalsVisibleTo("JsonApiDotNetCoreTests")] diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index d310028ae6..43161f99a8 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -93,6 +93,28 @@ private void AssertIsIdentifiableCollection(object newValue) } } + /// + /// Adds a resource to this to-many relationship on the specified resource instance. Throws if the property is read-only or if the field does not belong + /// to the specified resource instance. + /// + public void AddValue(object resource, IIdentifiable resourceToAdd) + { + ArgumentGuard.NotNull(resource); + ArgumentGuard.NotNull(resourceToAdd); + + object? rightValue = GetValue(resource); + List rightResources = CollectionConverter.ExtractResources(rightValue).ToList(); + + if (!rightResources.Exists(nextResource => nextResource == resourceToAdd)) + { + rightResources.Add(resourceToAdd); + + Type collectionType = rightValue?.GetType() ?? Property.PropertyType; + IEnumerable typedCollection = CollectionConverter.CopyToTypedCollection(rightResources, collectionType); + base.SetValue(resource, typedCollection); + } + } + /// public override bool Equals(object? obj) { diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index 34fc8971d1..b8d6038508 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Benchmarks")] +[assembly: InternalsVisibleTo("DapperExample")] [assembly: InternalsVisibleTo("JsonApiDotNetCoreTests")] [assembly: InternalsVisibleTo("UnitTests")] diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index 04b32b6499..63415cfffc 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public sealed class FieldSelectors : Dictionary { - public bool IsEmpty => !this.Any(); + public bool IsEmpty => Count == 0; public bool ContainsReadOnlyAttribute { @@ -24,7 +24,7 @@ public bool ContainsOnlyRelationships { get { - return this.All(selector => selector.Key is RelationshipAttribute); + return Count > 0 && this.All(selector => selector.Key is RelationshipAttribute); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index d7f80b8aa2..d5fac60c71 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -324,6 +324,11 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, IncludeExpression? innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; + if (relationship is HasOneAttribute) + { + secondaryLayer.Sort = null; + } + var primarySelection = new FieldSelection(); FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType); diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs similarity index 79% rename from src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs index c1db07b0fb..9c4351f0f7 100644 --- a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs @@ -1,18 +1,19 @@ -using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace NoEntityFrameworkExample; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; /// -/// Replaces all s with s. +/// Replaces all s with s in-place. /// -internal sealed class QueryLayerIncludeConverter : QueryExpressionVisitor +public sealed class QueryLayerIncludeConverter : QueryExpressionVisitor { private readonly QueryLayer _queryLayer; public QueryLayerIncludeConverter(QueryLayer queryLayer) { + ArgumentGuard.NotNull(queryLayer); + _queryLayer = queryLayer; } @@ -29,7 +30,7 @@ public void ConvertIncludesToSelections() public override object? VisitInclude(IncludeExpression expression, QueryLayer queryLayer) { - foreach (IncludeElementExpression element in expression.Elements) + foreach (IncludeElementExpression element in expression.Elements.OrderBy(element => element.Relationship.PublicName)) { _ = Visit(element, queryLayer); } @@ -41,7 +42,7 @@ public void ConvertIncludesToSelections() { QueryLayer subLayer = EnsureRelationshipInSelection(queryLayer, expression.Relationship); - foreach (IncludeElementExpression nextIncludeElement in expression.Children) + foreach (IncludeElementExpression nextIncludeElement in expression.Children.OrderBy(child => child.Relationship.PublicName)) { Visit(nextIncludeElement, subLayer); } @@ -69,13 +70,9 @@ private static void EnsureNonEmptySelection(QueryLayer queryLayer) { if (queryLayer.Selection == null) { + // Empty selection indicates to fetch all scalar properties. queryLayer.Selection = new FieldSelection(); - FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); - - foreach (AttrAttribute attribute in queryLayer.ResourceType.Attributes) - { - selectors.IncludeAttribute(attribute); - } + queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); } } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index d693469bd3..a1dd644f88 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -58,7 +58,7 @@ public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext c expression = ApplyPagination(expression, layer.Pagination, layer.ResourceType, context); } - if (layer.Selection is { IsEmpty: false }) + if (layer.Selection != null) { expression = ApplySelection(expression, layer.Selection, layer.ResourceType, context); } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index ce491304ef..3f23013d7c 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -119,10 +119,11 @@ private static ICollection ToPropertySelectors(FieldSelectors { var propertySelectors = new Dictionary(); - if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) + if (fieldSelectors.IsEmpty || fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) { // 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. + // Additionally, empty selectors (originating from eliminated includes) indicate to fetch all scalar properties too. IncludeAllScalarProperties(elementType, propertySelectors, entityModel); } diff --git a/test/DapperTests/DapperTests.csproj b/test/DapperTests/DapperTests.csproj new file mode 100644 index 0000000000..c7ce96a37a --- /dev/null +++ b/test/DapperTests/DapperTests.csproj @@ -0,0 +1,17 @@ + + + $(TargetFrameworkName) + + + + + + + + + + + + + + diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs new file mode 100644 index 0000000000..194d55d837 --- /dev/null +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -0,0 +1,522 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.AtomicOperations; + +public sealed class AtomicOperationsTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public AtomicOperationsTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_use_multiple_operations() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newOwner = _fakers.Person.Generate(); + Person newAssignee = _fakers.Person.Generate(); + Tag newTag = _fakers.Tag.Generate(); + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + const string ownerLocalId = "new-owner"; + const string assigneeLocalId = "new-assignee"; + const string tagLocalId = "new-tag"; + const string todoItemLocalId = "new-todoItem"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "people", + lid = ownerLocalId, + attributes = new + { + firstName = newOwner.FirstName, + lastName = newOwner.LastName + } + } + }, + new + { + op = "add", + data = new + { + type = "people", + lid = assigneeLocalId, + attributes = new + { + firstName = newAssignee.FirstName, + lastName = newAssignee.LastName + } + } + }, + new + { + op = "add", + data = new + { + type = "tags", + lid = tagLocalId, + attributes = new + { + name = newTag.Name + } + } + }, + new + { + op = "add", + data = new + { + type = "todoItems", + lid = todoItemLocalId, + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + lid = ownerLocalId + } + } + } + } + }, + new + { + op = "update", + @ref = new + { + type = "todoItems", + lid = todoItemLocalId, + relationship = "assignee" + }, + data = new + { + type = "people", + lid = assigneeLocalId + } + }, + new + { + op = "update", + data = new + { + type = "todoItems", + lid = todoItemLocalId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "tags", + lid = tagLocalId + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "people", + lid = assigneeLocalId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.ShouldHaveCount(7); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("people")); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("people")); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("tags")); + responseDocument.Results[3].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Results[4].Data.Value.Should().BeNull(); + responseDocument.Results[5].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Results[6].Data.Value.Should().BeNull(); + + long newOwnerId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newAssigneeId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newTagId = long.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); + long newTodoItemId = long.Parse(responseDocument.Results[3].Data.SingleValue!.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(newOwnerId); + todoItemInDatabase.Assignee.Should().BeNull(); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(newTagId); + }); + + store.SqlCommands.ShouldHaveCount(15); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newOwner.FirstName); + command.Parameters.Should().Contain("@p2", newOwner.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newOwnerId); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newAssignee.FirstName); + command.Parameters.Should().Contain("@p2", newAssignee.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newAssigneeId); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""Tags"" (""Name"", ""TodoItemId"") +VALUES (@p1, @p2) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newTag.Name); + command.Parameters.Should().Contain("@p2", null); + }); + + store.SqlCommands[5].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTagId); + }); + + store.SqlCommands[6].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") +VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", newOwnerId); + command.Parameters.Should().Contain("@p7", null); + }); + + store.SqlCommands[7].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[8].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[9].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newAssigneeId); + command.Parameters.Should().Contain("@p2", newTodoItemId); + }); + + store.SqlCommands[10].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[11].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""LastModifiedAt"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p2", newTodoItemId); + }); + + store.SqlCommands[12].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newTodoItemId); + command.Parameters.Should().Contain("@p2", newTagId); + }); + + store.SqlCommands[13].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[14].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""People"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newAssigneeId); + }); + } + + [Fact] + public async Task Can_rollback_on_error() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newPerson = _fakers.Person.Generate(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + const string personLocalId = "new-person"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "people", + lid = personLocalId, + attributes = new + { + lastName = newPerson.LastName + } + } + }, + new + { + op = "update", + @ref = new + { + type = "people", + lid = personLocalId, + relationship = "assignedTodoItems" + }, + data = new[] + { + new + { + type = "todoItems", + id = unknownTodoItemId.ToString() + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'todoItems' with ID '{unknownTodoItemId}' in relationship 'assignedTodoItems' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List peopleInDatabase = await dbContext.People.ToListAsync(); + peopleInDatabase.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(5); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", newPerson.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); + command.Parameters.Should().Contain("@p2", unknownTodoItemId); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs new file mode 100644 index 0000000000..084444e896 --- /dev/null +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using DapperExample; +using DapperExample.Data; +using DapperExample.Models; +using DapperExample.Repositories; +using DapperExample.TranslationToSql.DataModel; +using FluentAssertions.Common; +using FluentAssertions.Extensions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests; + +[PublicAPI] +public sealed class DapperTestContext : IntegrationTest +{ + private const string SqlServerClearAllTablesScript = @" + EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'; + EXEC sp_MSForEachTable 'SET QUOTED_IDENTIFIER ON; DELETE FROM ?'; + EXEC sp_MSForEachTable 'ALTER TABLE ? CHECK CONSTRAINT ALL';"; + + public static readonly DateTimeOffset FrozenTime = 29.September(2018).At(16, 41, 56).AsUtc().ToDateTimeOffset(); + + private readonly Lazy> _lazyFactory; + private ITestOutputHelper? _testOutputHelper; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = Factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public WebApplicationFactory Factory => _lazyFactory.Value; + + public DapperTestContext() + { + _lazyFactory = new Lazy>(CreateFactory); + } + + private WebApplicationFactory CreateFactory() + { + return new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:DapperExamplePostgreSql", + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"); + + builder.UseSetting("ConnectionStrings:DapperExampleMySql", + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=root;Password=mysql;SSL Mode=None"); + + builder.UseSetting("ConnectionStrings:DapperExampleSqlServer", + $"Server=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=sa;Password=Passw0rd!;TrustServerCertificate=true"); + + builder.UseSetting("Logging:LogLevel:DapperExample", "Debug"); + + builder.ConfigureLogging(loggingBuilder => + { + if (_testOutputHelper != null) + { + loggingBuilder.Services.AddSingleton(_ => new XUnitLoggerProvider(_testOutputHelper, "DapperExample.")); + } + }); + + builder.ConfigureServices(services => + { + services.AddSingleton(new FrozenSystemClock + { + UtcNow = FrozenTime + }); + + ServiceDescriptor scopedCaptureStore = services.Single(descriptor => descriptor.ImplementationType == typeof(SqlCaptureStore)); + services.Remove(scopedCaptureStore); + + services.AddSingleton(); + }); + }); + } + + public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + public async Task ClearAllTablesAsync(DbContext dbContext) + { + var dataModelService = Factory.Services.GetRequiredService(); + DatabaseProvider databaseProvider = dataModelService.DatabaseProvider; + + if (databaseProvider == DatabaseProvider.SqlServer) + { + await dbContext.Database.ExecuteSqlRawAsync(SqlServerClearAllTablesScript); + } + else + { + foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) + { + string? tableName = entityType.GetTableName(); + + string escapedTableName = databaseProvider switch + { + DatabaseProvider.PostgreSql => $"\"{tableName}\"", + DatabaseProvider.MySql => $"`{tableName}`", + _ => throw new NotSupportedException($"Unsupported database provider '{databaseProvider}'.") + }; + + await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM {escapedTableName}"); + } + } + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + + public string AdaptSql(string text, bool hasClientGeneratedId = false) + { + var dataModelService = Factory.Services.GetRequiredService(); + var adapter = new SqlTextAdapter(dataModelService.DatabaseProvider); + return adapter.Adapt(text, hasClientGeneratedId); + } + + protected override HttpClient CreateClient() + { + return Factory.CreateClient(); + } + + public override async Task DisposeAsync() + { + try + { + if (_lazyFactory.IsValueCreated) + { + try + { + await RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()); + } + finally + { + await _lazyFactory.Value.DisposeAsync(); + } + } + } + finally + { + await base.DisposeAsync(); + } + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs new file mode 100644 index 0000000000..d23a90765a --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs @@ -0,0 +1,1244 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class FilterTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FilterTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_filter_equals_on_obfuscated_id_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.Generate(3); + tags.ForEach(tag => tag.Color = _fakers.RgbColor.Generate()); + + tags[0].Color!.StringId = "FF0000"; + tags[1].Color!.StringId = "00FF00"; + tags[2].Color!.StringId = "0000FF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/tags?filter=equals(color.id,'00FF00')"; + + // 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("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(tags[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 0x00FF00); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" = @p1 +ORDER BY t1.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 0x00FF00); + }); + } + + [Fact] + public async Task Can_filter_any_on_obfuscated_id_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.Generate(3); + tags.ForEach(tag => tag.Color = _fakers.RgbColor.Generate()); + + tags[0].Color!.StringId = "FF0000"; + tags[1].Color!.StringId = "00FF00"; + tags[2].Color!.StringId = "0000FF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/tags?filter=any(color.id,'00FF00','11EE11')"; + + // 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("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(tags[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 0x00FF00); + command.Parameters.Should().Contain("@p2", 0x11EE11); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" IN (@p1, @p2) +ORDER BY t1.""Id""")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 0x00FF00); + command.Parameters.Should().Contain("@p2", 0x11EE11); + }); + } + + [Fact] + public async Task Can_filter_equals_null_on_relationship_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=equals(assignee,null)"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +LEFT JOIN ""People"" AS t3 ON t1.""AssigneeId"" = t3.""Id"" +WHERE (t2.""Id"" = @p1) AND (t3.""Id"" IS NULL)")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" + WHERE t3.""Id"" IS NULL +) AS t4 ON t1.""Id"" = t4.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY t4.""Priority"", t4.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_filter_equals_null_on_attribute_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).DurationInHours = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=equals(durationInHours,null)"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t2.""Id"" = @p1) AND (t1.""DurationInHours"" IS NULL)")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""DurationInHours"" IS NULL +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_filter_equals_on_enum_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + todoItems.ForEach(todoItem => todoItem.Priority = TodoItemPriority.Low); + + todoItems[1].Priority = TodoItemPriority.Medium; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(priority,'Medium')"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE t1.""Priority"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItems[1].Priority); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Priority"" = @p1 +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItems[1].Priority); + }); + } + + [Fact] + public async Task Can_filter_equals_on_string_attribute_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + person.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + person.AssignedTodoItems.ElementAt(1).Description = "Take exam"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/assignedTodoItems?filter=equals(description,'Take exam')"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.AssignedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE (t2.""Id"" = @p1) AND (t1.""Description"" = @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", person.AssignedTodoItems.ElementAt(1).Description); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""AssigneeId"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Description"" = @p2 +) AS t3 ON t1.""Id"" = t3.""AssigneeId"" +WHERE t1.""Id"" = @p1 +ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", person.AssignedTodoItems.ElementAt(1).Description); + }); + } + + [Fact] + public async Task Can_filter_equality_on_attributes_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + todoItems.ForEach(todoItem => todoItem.Assignee = _fakers.Person.Generate()); + + todoItems[1].Assignee!.FirstName = todoItems[1].Assignee!.LastName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(assignee.lastName,assignee.firstName)"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t2.""LastName"" = t2.""FirstName""")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t2.""LastName"" = t2.""FirstName"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_any_with_single_constant_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Priority = TodoItemPriority.Low; + person.OwnedTodoItems.ElementAt(1).Priority = TodoItemPriority.Medium; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=any(priority,'Medium')"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t2.""Id"" = @p1) AND (t1.""Priority"" = @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", TodoItemPriority.Medium); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Priority"" = @p2 +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", TodoItemPriority.Medium); + }); + } + + [Fact] + public async Task Can_filter_not_not_not_not_equals_on_string_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Description = "X"; + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=not(not(not(not(equals(description,'X')))))"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE t1.""Description"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Description"" = @p1 +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Can_filter_not_equals_on_nullable_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List people = _fakers.Person.Generate(3); + people[0].FirstName = "X"; + people[1].FirstName = null; + people[2].FirstName = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(people); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=not(equals(firstName,'X'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("people")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == people[1].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == people[2].StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1 +WHERE (NOT (t1.""FirstName"" = @p1)) OR (t1.""FirstName"" IS NULL)")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE (NOT (t1.""FirstName"" = @p1)) OR (t1.""FirstName"" IS NULL) +ORDER BY t1.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Can_filter_not_equals_on_attributes_of_optional_relationship_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[1].Assignee = _fakers.Person.Generate(); + todoItems[1].Assignee!.FirstName = "X"; + todoItems[1].Assignee!.LastName = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=not(and(equals(assignee.firstName,'X'),equals(assignee.lastName,'Y')))"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[0].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE (NOT ((t2.""FirstName"" = @p1) AND (t2.""LastName"" = @p2))) OR (t2.""FirstName"" IS NULL) OR (t2.""LastName"" IS NULL)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE (NOT ((t2.""FirstName"" = @p1) AND (t2.""LastName"" = @p2))) OR (t2.""FirstName"" IS NULL) OR (t2.""LastName"" IS NULL) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + } + + [Fact] + public async Task Can_filter_text_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Description = "One"; + todoItems[1].Description = "Two"; + todoItems[1].Owner.FirstName = "Jack"; + todoItems[2].Description = "Three"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?filter=and(startsWith(description,'T'),not(any(description,'Three','Four')),equals(owner.firstName,'Jack'),contains(description,'o'))"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t1.""Description"" LIKE 'T%') AND (NOT (t1.""Description"" IN (@p1, @p2))) AND (t2.""FirstName"" = @p3) AND (t1.""Description"" LIKE '%o%')")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Four"); + command.Parameters.Should().Contain("@p2", "Three"); + command.Parameters.Should().Contain("@p3", "Jack"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t1.""Description"" LIKE 'T%') AND (NOT (t1.""Description"" IN (@p1, @p2))) AND (t2.""FirstName"" = @p3) AND (t1.""Description"" LIKE '%o%') +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Four"); + command.Parameters.Should().Contain("@p2", "Three"); + command.Parameters.Should().Contain("@p3", "Jack"); + }); + } + + [Fact] + public async Task Can_filter_special_characters_in_text_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.Generate(6); + tags[0].Name = "A%Z"; + tags[1].Name = "A_Z"; + tags[2].Name = @"A\Z"; + tags[3].Name = "A'Z"; + tags[4].Name = @"A%_\'Z"; + tags[5].Name = "AZ"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = @"/tags?filter=or(contains(name,'A%'),contains(name,'A_'),contains(name,'A\'),contains(name,'A'''),contains(name,'%_\'''))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(5); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[0].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[1].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[2].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[3].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[4].StringId); + + responseDocument.Meta.Should().ContainTotal(5); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +WHERE (t1.""Name"" LIKE '%A\%%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\_%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\\%' ESCAPE '\') OR (t1.""Name"" LIKE '%A''%') OR (t1.""Name"" LIKE '%\%\_\\''%' ESCAPE '\')")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE (t1.""Name"" LIKE '%A\%%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\_%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\\%' ESCAPE '\') OR (t1.""Name"" LIKE '%A''%') OR (t1.""Name"" LIKE '%\%\_\\''%' ESCAPE '\') +ORDER BY t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_numeric_range_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].DurationInHours = 100; + todoItems[1].DurationInHours = 200; + todoItems[2].DurationInHours = 300; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=or(greaterThan(durationInHours,'250'),lessOrEqual(durationInHours,'100'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[0].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[2].StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE (t1.""DurationInHours"" > @p1) OR (t1.""DurationInHours"" <= @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 250); + command.Parameters.Should().Contain("@p2", 100); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE (t1.""DurationInHours"" > @p1) OR (t1.""DurationInHours"" <= @p2) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 250); + command.Parameters.Should().Contain("@p2", 100); + }); + } + + [Fact] + public async Task Can_filter_count_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[1].Owner.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + todoItems[1].Owner.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=and(greaterThan(count(owner.assignedTodoItems),'1'),not(equals(owner,null)))"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t4 ON t1.""OwnerId"" = t4.""Id"" +WHERE (( + SELECT COUNT(*) + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + WHERE t1.""OwnerId"" = t2.""Id"" +) > @p1) AND (NOT (t4.""Id"" IS NULL))")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 1); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t4 ON t1.""OwnerId"" = t4.""Id"" +WHERE (( + SELECT COUNT(*) + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + WHERE t1.""OwnerId"" = t2.""Id"" +) > @p1) AND (NOT (t4.""Id"" IS NULL)) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 1); + }); + } + + [Fact] + public async Task Can_filter_nested_conditional_has_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[1].Owner.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + + todoItems[1].Owner.AssignedTodoItems.ForEach(todoItem => + { + todoItem.Description = "Homework"; + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Owner.LastName = "Smith"; + todoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + }); + + todoItems[1].Owner.AssignedTodoItems.ElementAt(1).Tags.ElementAt(0).Name = "Personal"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?filter=has(owner.assignedTodoItems,and(has(tags,equals(name,'Personal')),equals(owner.lastName,'Smith'),equals(description,'Homework')))"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + INNER JOIN ""People"" AS t5 ON t3.""OwnerId"" = t5.""Id"" + WHERE (t1.""OwnerId"" = t2.""Id"") AND (EXISTS ( + SELECT 1 + FROM ""Tags"" AS t4 + WHERE (t3.""Id"" = t4.""TodoItemId"") AND (t4.""Name"" = @p1) + )) AND (t5.""LastName"" = @p2) AND (t3.""Description"" = @p3) +)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Personal"); + command.Parameters.Should().Contain("@p2", "Smith"); + command.Parameters.Should().Contain("@p3", "Homework"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + INNER JOIN ""People"" AS t5 ON t3.""OwnerId"" = t5.""Id"" + WHERE (t1.""OwnerId"" = t2.""Id"") AND (EXISTS ( + SELECT 1 + FROM ""Tags"" AS t4 + WHERE (t3.""Id"" = t4.""TodoItemId"") AND (t4.""Name"" = @p1) + )) AND (t5.""LastName"" = @p2) AND (t3.""Description"" = @p3) +) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Personal"); + command.Parameters.Should().Contain("@p2", "Smith"); + command.Parameters.Should().Contain("@p3", "Homework"); + }); + } + + [Fact] + public async Task Can_filter_conditional_has_with_null_check_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List people = _fakers.Person.Generate(3); + people.ForEach(person => person.OwnedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet()); + + people[0].OwnedTodoItems.ElementAt(0).Assignee = null; + + people[1].OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.Generate(); + + people[2].OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.Generate(); + people[2].OwnedTodoItems.ElementAt(0).Assignee!.FirstName = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(people); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=has(ownedTodoItems,and(not(equals(assignee,null)),equals(assignee.firstName,null)))"; + + // 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("people"); + responseDocument.Data.ManyValue[0].Id.Should().Be(people[2].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" + WHERE (t1.""Id"" = t2.""OwnerId"") AND (NOT (t3.""Id"" IS NULL)) AND (t3.""FirstName"" IS NULL) +)")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" + WHERE (t1.""Id"" = t2.""OwnerId"") AND (NOT (t3.""Id"" IS NULL)) AND (t3.""FirstName"" IS NULL) +) +ORDER BY t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_using_logical_operators_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(5); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Description = "0"; + todoItems[0].Priority = TodoItemPriority.High; + todoItems[0].DurationInHours = 1; + + todoItems[1].Description = "1"; + todoItems[1].Priority = TodoItemPriority.Low; + todoItems[1].DurationInHours = 0; + + todoItems[2].Description = "1"; + todoItems[2].Priority = TodoItemPriority.Low; + todoItems[2].DurationInHours = 1; + + todoItems[3].Description = "1"; + todoItems[3].Priority = TodoItemPriority.High; + todoItems[3].DurationInHours = 0; + + todoItems[4].Description = "1"; + todoItems[4].Priority = TodoItemPriority.High; + todoItems[4].DurationInHours = 1; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=and(equals(description,'1'),or(equals(priority,'High'),equals(durationInHours,'1')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[2].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[3].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[4].StringId); + + responseDocument.Meta.Should().ContainTotal(3); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE (t1.""Description"" = @p1) AND ((t1.""Priority"" = @p2) OR (t1.""DurationInHours"" = @p3))")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "1"); + command.Parameters.Should().Contain("@p2", TodoItemPriority.High); + command.Parameters.Should().Contain("@p3", 1); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE (t1.""Description"" = @p1) AND ((t1.""Priority"" = @p2) OR (t1.""DurationInHours"" = @p3)) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "1"); + command.Parameters.Should().Contain("@p2", TodoItemPriority.High); + command.Parameters.Should().Contain("@p3", 1); + }); + } + + [Fact] + public async Task Cannot_filter_on_unmapped_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=equals(displayName,'John Doe')"; + + // 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("Sorting or filtering on the requested attribute is unavailable."); + error.Detail.Should().Be("Sorting or filtering on attribute 'displayName' is unavailable because it is unmapped."); + error.Source.Should().BeNull(); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs new file mode 100644 index 0000000000..153e028b01 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class IncludeTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public IncludeTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources_with_multiple_include_chains() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person owner = _fakers.Person.Generate(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = owner); + todoItems.ForEach(todoItem => todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet()); + todoItems[1].Assignee = _fakers.Person.Generate(); + + todoItems[0].Priority = TodoItemPriority.High; + todoItems[1].Priority = TodoItemPriority.Low; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=owner.assignedTodoItems,assignee,tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[0].StringId); + + responseDocument.Data.ManyValue[0].Relationships.With(relationships => + { + relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[0].Owner.StringId); + }); + + relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.Should().BeNull(); + }); + + relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(2); + value.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + value.Data.ManyValue[0].Id.Should().Be(todoItems[0].Tags.ElementAt(0).StringId); + value.Data.ManyValue[1].Id.Should().Be(todoItems[0].Tags.ElementAt(1).StringId); + }); + }); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Data.ManyValue[1].Relationships.With(relationships => + { + relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[1].Owner.StringId); + }); + + relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[1].Assignee!.StringId); + }); + + relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(2); + value.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + value.Data.ManyValue[0].Id.Should().Be(todoItems[1].Tags.ElementAt(0).StringId); + value.Data.ManyValue[1].Id.Should().Be(todoItems[1].Tags.ElementAt(1).StringId); + }); + }); + + responseDocument.Included.ShouldHaveCount(6); + + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(owner.StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(owner.FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(owner.LastName)); + + responseDocument.Included[1].Type.Should().Be("tags"); + responseDocument.Included[1].Id.Should().Be(todoItems[0].Tags.ElementAt(0).StringId); + responseDocument.Included[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[0].Tags.ElementAt(0).Name)); + + responseDocument.Included[2].Type.Should().Be("tags"); + responseDocument.Included[2].Id.Should().Be(todoItems[0].Tags.ElementAt(1).StringId); + responseDocument.Included[2].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[0].Tags.ElementAt(1).Name)); + + responseDocument.Included[3].Type.Should().Be("people"); + responseDocument.Included[3].Id.Should().Be(todoItems[1].Assignee!.StringId); + responseDocument.Included[3].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(todoItems[1].Assignee!.FirstName)); + responseDocument.Included[3].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItems[1].Assignee!.LastName)); + + responseDocument.Included[4].Type.Should().Be("tags"); + responseDocument.Included[4].Id.Should().Be(todoItems[1].Tags.ElementAt(0).StringId); + responseDocument.Included[4].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[1].Tags.ElementAt(0).Name)); + + responseDocument.Included[5].Type.Should().Be("tags"); + responseDocument.Included[5].Id.Should().Be(todoItems[1].Tags.ElementAt(1).StringId); + responseDocument.Included[5].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[1].Tags.ElementAt(1).Name)); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""FirstName"", t3.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"", t5.""Id"", t5.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" +LEFT JOIN ""TodoItems"" AS t4 ON t3.""Id"" = t4.""AssigneeId"" +LEFT JOIN ""Tags"" AS t5 ON t1.""Id"" = t5.""TodoItemId"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, t4.""Priority"", t4.""LastModifiedAt"" DESC, t5.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_get_primary_resources_with_includes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(25); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + todoItems.ForEach(todoItem => todoItem.Tags = _fakers.Tag.Generate(15).ToHashSet()); + todoItems.ForEach(todoItem => todoItem.Tags.ForEach(tag => tag.Color = _fakers.RgbColor.Generate())); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=tags.color"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(25); + + responseDocument.Data.ManyValue.ForEach(resource => + { + resource.Type.Should().Be("todoItems"); + resource.Attributes.ShouldOnlyContainKeys("description", "priority", "durationInHours", "createdAt", "modifiedAt"); + resource.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + }); + + responseDocument.Included.ShouldHaveCount(25 * 15 * 2); + + responseDocument.Meta.Should().ContainTotal(25); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""Name"", t3.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +LEFT JOIN ""RgbColors"" AS t3 ON t2.""Id"" = t3.""TagId"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, t2.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs new file mode 100644 index 0000000000..137ba693f0 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using DapperExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class PaginationTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public PaginationTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Cannot_use_pagination() + { + // Arrange + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?page[size]=3"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing this request."); + error.Detail.Should().Be("Pagination is not supported."); + error.Source.Should().BeNull(); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs new file mode 100644 index 0000000000..bdde400b84 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs @@ -0,0 +1,410 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class SortTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SortTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_sort_on_attributes_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Description = "B"; + todoItems[1].Description = "A"; + todoItems[2].Description = "C"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?sort=-description,durationInHours,id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[2].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(todoItems[1].StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +ORDER BY t1.""Description"" DESC, t1.""DurationInHours"", t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_sort_on_attributes_in_secondary_and_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).DurationInHours = 40; + person.OwnedTodoItems.ElementAt(1).DurationInHours = 100; + person.OwnedTodoItems.ElementAt(2).DurationInHours = 250; + + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(2).ToHashSet(); + + person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(0).Name = "B"; + person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(1).Name = "A"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?include=tags&sort=-durationInHours&sort[tags]=name"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Included[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(1).StringId); + responseDocument.Included[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(0).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t3.""Id"", t3.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +LEFT JOIN ""Tags"" AS t3 ON t2.""Id"" = t3.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""DurationInHours"" DESC, t3.""Name""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Tags = _fakers.Tag.Generate(2).ToHashSet(); + todoItems[1].Tags = _fakers.Tag.Generate(1).ToHashSet(); + todoItems[2].Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[2].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(todoItems[1].StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +ORDER BY ( + SELECT COUNT(*) + FROM ""Tags"" AS t2 + WHERE t1.""Id"" = t2.""TodoItemId"" +) DESC, t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(1).ToHashSet(); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +) DESC, t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_secondary_resources_with_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(1).ToHashSet(); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?include=tags&sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t4.""Id"", t4.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +) DESC, t2.""Id"", t4.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(4).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(1).ToHashSet(); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=-count(tags),id"; + + // 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("people"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.StringId); + + responseDocument.Included.ShouldHaveCount(4); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Included[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Included[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Included[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + responseDocument.Included[3].Id.Should().Be(person.OwnedTodoItems.ElementAt(3).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +) DESC, t2.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs new file mode 100644 index 0000000000..719b3b2d36 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs @@ -0,0 +1,393 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class SparseFieldSets : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SparseFieldSets(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_select_fields_in_primary_and_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=owner,assignee&fields[todoItems]=description,durationInHours,owner,assignee&fields[people]=lastName"; + + // 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("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItem.DurationInHours)); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + }); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItem.Assignee.StringId); + }); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("people")); + + responseDocument.Included[0].Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItem.Owner.LastName)); + responseDocument.Included[0].Relationships.Should().BeNull(); + + responseDocument.Included[1].Id.Should().Be(todoItem.Assignee.StringId); + responseDocument.Included[1].Attributes.ShouldHaveCount(1); + responseDocument.Included[1].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItem.Assignee.LastName)); + responseDocument.Included[1].Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""Description"", t1.""DurationInHours"", t2.""Id"", t2.""LastName"", t3.""Id"", t3.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_select_attribute_in_primary_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}?fields[todoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Description"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_select_relationship_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/tags?fields[tags]=color"; + + // 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("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("color").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_select_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_select_empty_fieldset() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]="; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Fetches_all_scalar_properties_when_fieldset_contains_readonly_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]=displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(person.DisplayName)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Returns_related_resources_on_broken_resource_linkage() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}?include=tags&fields[todoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Description"", t2.""Id"", t2.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..80206b0750 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class AddToToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public AddToToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet(); + + List existingTodoItems = _fakers.TodoItem.Generate(2); + existingTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + dbContext.TodoItems.AddRange(existingTodoItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(1).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.ShouldHaveCount(3); + }); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""OwnerId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItems.ElementAt(1).Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs new file mode 100644 index 0000000000..22ebd01a4e --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class FetchRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FetchRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_empty_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Value.Should().BeNull(); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItem.Tags.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_ID() + { + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'todoItems' with ID '{unknownTodoItemId}' does not exist."); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..c118d8ff59 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class RemoveFromToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public RemoveFromToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_nullable_foreign_key() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingPerson.AssignedTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingPerson.AssignedTodoItems.ElementAt(2).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.ShouldHaveCount(1); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingPerson.AssignedTodoItems.ElementAt(1).Id); + + List todoItemInDatabases = await dbContext.TodoItems.Where(todoItem => todoItem.Assignee == null).ToListAsync(); + + todoItemInDatabases.Should().HaveCount(2); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t3.""Id"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""AssigneeId"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Id"" IN (@p2, @p3) +) AS t3 ON t1.""Id"" = t3.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_required_foreign_key() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingPerson.OwnedTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingPerson.OwnedTodoItems.ElementAt(2).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.ShouldHaveCount(1); + personInDatabase.OwnedTodoItems.ElementAt(0).Id.Should().Be(existingPerson.OwnedTodoItems.ElementAt(1).Id); + + List todoItemInDatabases = await dbContext.TodoItems.ToListAsync(); + + todoItemInDatabases.Should().HaveCount(1); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t3.""Id"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""OwnerId"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Id"" IN (@p2, @p3) +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..e1bbcf1190 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,402 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class ReplaceToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public ReplaceToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_OneToMany_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_clear_OneToMany_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + existingPerson.OwnedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_create_OneToMany_relationship() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + + List existingTodoItems = _fakers.TodoItem.Generate(2); + existingTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + dbContext.TodoItems.AddRange(existingTodoItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(1).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.ShouldHaveCount(2); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItems.ElementAt(0).Id); + personInDatabase.AssignedTodoItems.ElementAt(1).Id.Should().Be(existingTodoItems.ElementAt(1).Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet(); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.ShouldHaveCount(1); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet(); + existingPerson.OwnedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.ShouldHaveCount(1); + personInDatabase.OwnedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""OwnerId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..5ed90caa2a --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,1140 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class UpdateToOneRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public UpdateToOneRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.Account = _fakers.LoginAccount.Generate(); + existingPerson.Account.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/people/{existingPerson.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.Account.Should().BeNull(); + + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingPerson.Account.Id); + + loginAccountInDatabase.Person.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + existingLoginAccount.Person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount.Id); + + loginAccountInDatabase.Person.Should().BeNull(); + + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingLoginAccount.Person.Id); + + personInDatabase.Account.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount.Person.Id); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_right_side_when_already_null() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + } + + [Fact] + public async Task Cannot_clear_OneToOne_relationship_with_required_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/recovery"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'recovery' on resource type 'loginAccounts' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""EmailAddress"", t2.""PhoneNumber"" +FROM ""LoginAccounts"" AS t1 +INNER JOIN ""AccountRecoveries"" AS t2 ON t1.""RecoveryId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + } + + [Fact] + public async Task Cannot_clear_OneToOne_relationship_with_required_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery = _fakers.AccountRecovery.Generate(); + existingAccountRecovery.Account = _fakers.LoginAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AccountRecoveries.Add(existingAccountRecovery); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/accountRecoveries/{existingAccountRecovery.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'account' on resource type 'accountRecoveries' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""EmailAddress"", t1.""PhoneNumber"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""AccountRecoveries"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""Id"" = t2.""RecoveryId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); + }); + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem.Id); + + todoItemInDatabase.Assignee.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Cannot_clear_ManyToOne_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'owner' on resource type 'todoItems' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingLoginAccount.StringId + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.Account.ShouldNotBeNull(); + personInDatabase.Account.Id.Should().Be(existingLoginAccount.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""AccountId"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLoginAccount, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount.Id); + + loginAccountInDatabase.Person.ShouldNotBeNull(); + loginAccountInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_create_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTodoItem, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem.Id); + + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson1 = _fakers.Person.Generate(); + existingPerson1.Account = _fakers.LoginAccount.Generate(); + existingPerson1.Account.Recovery = _fakers.AccountRecovery.Generate(); + + Person existingPerson2 = _fakers.Person.Generate(); + existingPerson2.Account = _fakers.LoginAccount.Generate(); + existingPerson2.Account.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(existingPerson1, existingPerson2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingPerson2.Account.StringId + } + }; + + string route = $"/people/{existingPerson1.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase1 = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson1.Id); + + personInDatabase1.Account.ShouldNotBeNull(); + personInDatabase1.Account.Id.Should().Be(existingPerson2.Account.Id); + + Person personInDatabase2 = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson2.Id); + + personInDatabase2.Account.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""AccountId"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson2.Account.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson2.Account.Id); + command.Parameters.Should().Contain("@p2", existingPerson1.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount1 = _fakers.LoginAccount.Generate(); + existingLoginAccount1.Recovery = _fakers.AccountRecovery.Generate(); + existingLoginAccount1.Person = _fakers.Person.Generate(); + + LoginAccount existingLoginAccount2 = _fakers.LoginAccount.Generate(); + existingLoginAccount2.Recovery = _fakers.AccountRecovery.Generate(); + existingLoginAccount2.Person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.AddRange(existingLoginAccount1, existingLoginAccount2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingLoginAccount2.Person.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount1.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase1 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount1.Id); + + loginAccountInDatabase1.Person.ShouldNotBeNull(); + loginAccountInDatabase1.Person.Id.Should().Be(existingLoginAccount2.Person.Id); + + LoginAccount loginAccountInDatabase2 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount2.Id); + + loginAccountInDatabase2.Person.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount1.Person.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + command.Parameters.Should().Contain("@p2", existingLoginAccount2.Person.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_required_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount1 = _fakers.LoginAccount.Generate(); + existingLoginAccount1.Recovery = _fakers.AccountRecovery.Generate(); + + LoginAccount existingLoginAccount2 = _fakers.LoginAccount.Generate(); + existingLoginAccount2.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.AddRange(existingLoginAccount1, existingLoginAccount2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "accountRecoveries", + id = existingLoginAccount2.Recovery.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount1.StringId}/relationships/recovery"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase1 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Recovery).FirstWithIdAsync(existingLoginAccount1.Id); + + loginAccountInDatabase1.Recovery.ShouldNotBeNull(); + loginAccountInDatabase1.Recovery.Id.Should().Be(existingLoginAccount2.Recovery.Id); + + LoginAccount? loginAccountInDatabase2 = await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Recovery) + .FirstWithIdOrDefaultAsync(existingLoginAccount2.Id); + + loginAccountInDatabase2.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""EmailAddress"", t2.""PhoneNumber"" +FROM ""LoginAccounts"" AS t1 +INNER JOIN ""AccountRecoveries"" AS t2 ON t1.""RecoveryId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" +WHERE ""RecoveryId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""LoginAccounts"" +SET ""RecoveryId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); + command.Parameters.Should().Contain("@p2", existingLoginAccount1.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_required_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery1 = _fakers.AccountRecovery.Generate(); + existingAccountRecovery1.Account = _fakers.LoginAccount.Generate(); + + AccountRecovery existingAccountRecovery2 = _fakers.AccountRecovery.Generate(); + existingAccountRecovery2.Account = _fakers.LoginAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AccountRecoveries.AddRange(existingAccountRecovery1, existingAccountRecovery2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingAccountRecovery2.Account.StringId + } + }; + + string route = $"/accountRecoveries/{existingAccountRecovery1.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + AccountRecovery accountRecoveryInDatabase1 = + await dbContext.AccountRecoveries.Include(recovery => recovery.Account).FirstWithIdAsync(existingAccountRecovery1.Id); + + accountRecoveryInDatabase1.Account.ShouldNotBeNull(); + accountRecoveryInDatabase1.Account.Id.Should().Be(existingAccountRecovery2.Account.Id); + + AccountRecovery accountRecoveryInDatabase2 = + await dbContext.AccountRecoveries.Include(recovery => recovery.Account).FirstWithIdAsync(existingAccountRecovery2.Id); + + accountRecoveryInDatabase2.Account.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""EmailAddress"", t1.""PhoneNumber"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""AccountRecoveries"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""Id"" = t2.""RecoveryId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Account.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""LoginAccounts"" +SET ""RecoveryId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); + command.Parameters.Should().Contain("@p2", existingAccountRecovery2.Account.Id); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem1 = _fakers.TodoItem.Generate(); + existingTodoItem1.Owner = _fakers.Person.Generate(); + existingTodoItem1.Assignee = _fakers.Person.Generate(); + + TodoItem existingTodoItem2 = _fakers.TodoItem.Generate(); + existingTodoItem2.Owner = _fakers.Person.Generate(); + existingTodoItem2.Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(existingTodoItem1, existingTodoItem2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingTodoItem2.Assignee.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem1.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase1 = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem1.Id); + + todoItemInDatabase1.Assignee.ShouldNotBeNull(); + todoItemInDatabase1.Assignee.Id.Should().Be(existingTodoItem2.Assignee.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem2.Assignee.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem1.Id); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem1 = _fakers.TodoItem.Generate(); + existingTodoItem1.Owner = _fakers.Person.Generate(); + + TodoItem existingTodoItem2 = _fakers.TodoItem.Generate(); + existingTodoItem2.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(existingTodoItem1, existingTodoItem2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingTodoItem2.Owner.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem1.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase1 = await dbContext.TodoItems.Include(todoItem => todoItem.Owner).FirstWithIdAsync(existingTodoItem1.Id); + + todoItemInDatabase1.Owner.ShouldNotBeNull(); + todoItemInDatabase1.Owner.Id.Should().Be(existingTodoItem2.Owner.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""OwnerId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem2.Owner.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem1.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs new file mode 100644 index 0000000000..88ee185132 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs @@ -0,0 +1,732 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class CreateResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public CreateResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + Tag existingTag = _fakers.Tag.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }, + assignee = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }, + tags = new + { + data = new[] + { + new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newTodoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newTodoItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().BeNull()); + + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + long newTodoItemId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + httpResponse.Headers.Location.Should().Be($"/todoItems/{newTodoItemId}"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().BeNull(); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") +VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", existingPerson.Id); + command.Parameters.Should().Contain("@p7", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newTodoItemId); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + } + + [Fact] + public async Task Can_create_resource_with_only_required_fields() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newTodoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + long newTodoItemId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().BeNull(); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().BeNull(); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Assignee.Should().BeNull(); + todoItemInDatabase.Tags.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") +VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", null); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", existingPerson.Id); + command.Parameters.Should().Contain("@p7", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + } + + [Fact] + public async Task Cannot_create_resource_without_required_fields() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Owner field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/relationships/owner/data"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Priority field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/priority"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error3.Title.Should().Be("Input validation failed."); + error3.Detail.Should().Be("The Description field is required."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/data/attributes/description"); + + store.SqlCommands.Should().BeEmpty(); + } + + [Fact] + public async Task Can_create_resource_with_unmapped_property() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery = _fakers.AccountRecovery.Generate(); + Person existingPerson = _fakers.Person.Generate(); + + string newUserName = _fakers.LoginAccount.Generate().UserName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingAccountRecovery, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + attributes = new + { + userName = newUserName + }, + relationships = new + { + recovery = new + { + data = new + { + type = "accountRecoveries", + id = existingAccountRecovery.StringId + } + }, + person = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + const string route = "/loginAccounts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("loginAccounts"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("userName").With(value => value.Should().Be(newUserName)); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("lastUsedAt"); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("recovery", "person"); + + long newLoginAccountId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + LoginAccount loginAccountInDatabase = await dbContext.LoginAccounts + .Include(todoItem => todoItem.Recovery) + .Include(todoItem => todoItem.Person) + .FirstWithIdAsync(newLoginAccountId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + loginAccountInDatabase.UserName.Should().Be(newUserName); + loginAccountInDatabase.LastUsedAt.Should().BeNull(); + + loginAccountInDatabase.Recovery.ShouldNotBeNull(); + loginAccountInDatabase.Recovery.Id.Should().Be(existingAccountRecovery.Id); + loginAccountInDatabase.Person.ShouldNotBeNull(); + loginAccountInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.ShouldHaveCount(4); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" +WHERE ""RecoveryId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""LoginAccounts"" (""UserName"", ""LastUsedAt"", ""RecoveryId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newUserName); + command.Parameters.Should().Contain("@p2", null); + command.Parameters.Should().Contain("@p3", existingAccountRecovery.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newLoginAccountId); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"" +FROM ""LoginAccounts"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newLoginAccountId); + }); + } + + [Fact] + public async Task Can_create_resource_with_calculated_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newPerson = _fakers.Person.Generate(); + + var requestBody = new + { + data = new + { + type = "people", + attributes = new + { + firstName = newPerson.FirstName, + lastName = newPerson.LastName + } + } + }; + + const string route = "/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(newPerson.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(newPerson.LastName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newPerson.DisplayName)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); + + long newPersonId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.FirstWithIdAsync(newPersonId); + + personInDatabase.FirstName.Should().Be(newPerson.FirstName); + personInDatabase.LastName.Should().Be(newPerson.LastName); + personInDatabase.DisplayName.Should().Be(newPerson.DisplayName); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newPerson.FirstName); + command.Parameters.Should().Contain("@p2", newPerson.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newPersonId); + }); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Tag existingTag = _fakers.Tag.Generate(); + + RgbColor newColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + relationships = new + { + tag = new + { + data = new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + }; + + const string route = "/rgbColors/"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + RgbColor colorInDatabase = await dbContext.RgbColors.Include(rgbColor => rgbColor.Tag).FirstWithIdAsync(newColor.Id); + + colorInDatabase.Red.Should().Be(newColor.Red); + colorInDatabase.Green.Should().Be(newColor.Green); + colorInDatabase.Blue.Should().Be(newColor.Blue); + + colorInDatabase.Tag.ShouldNotBeNull(); + colorInDatabase.Tag.Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""RgbColors"" +WHERE ""TagId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""RgbColors"" (""Id"", ""TagId"") +VALUES (@p1, @p2) +RETURNING ""Id""", true)); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newColor.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""RgbColors"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newColor.Id); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + RgbColor existingColor = _fakers.RgbColor.Generate(); + existingColor.Tag = _fakers.Tag.Generate(); + + Tag existingTag = _fakers.Tag.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.AddInRange(existingColor, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + relationships = new + { + tag = new + { + data = new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + }; + + const string route = "/rgbColors"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Another resource with the specified ID already exists."); + error.Detail.Should().Be($"Another resource of type 'rgbColors' with ID '{existingColor.StringId}' already exists."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""RgbColors"" +WHERE ""TagId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""RgbColors"" (""Id"", ""TagId"") +VALUES (@p1, @p2) +RETURNING ""Id""", true)); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingColor.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""RgbColors"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingColor.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs new file mode 100644 index 0000000000..94f6d17b60 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class DeleteResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public DeleteResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + existingTodoItem.Tags.ElementAt(0).Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem? todoItemInDatabase = await dbContext.TodoItems.FirstWithIdOrDefaultAsync(existingTodoItem.Id); + + todoItemInDatabase.Should().BeNull(); + + List tags = await dbContext.Tags.Where(tag => tag.TodoItem == null).ToListAsync(); + + tags.ShouldHaveCount(1); + }); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Cannot_delete_unknown_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'todoItems' with ID '{unknownTodoItemId}' does not exist."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs new file mode 100644 index 0000000000..327a0b3051 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs @@ -0,0 +1,337 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class FetchResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FetchResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Priority = TodoItemPriority.Low; + todoItems[1].Priority = TodoItemPriority.High; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItems[1].Description)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(todoItems[1].Priority)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItems[1].DurationInHours)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(todoItems[1].CreatedAt)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(todoItems[1].LastModifiedAt)); + responseDocument.Data.ManyValue[0].Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItems[0].Description)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(todoItems[0].Priority)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItems[0].DurationInHours)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(todoItems[0].CreatedAt)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(todoItems[0].LastModifiedAt)); + responseDocument.Data.ManyValue[1].Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(todoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(todoItem.CreatedAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(todoItem.LastModifiedAt)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Cannot_get_unknown_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'todoItems' with ID '{unknownTodoItemId}' does not exist."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } + + [Fact] + public async Task Can_get_secondary_ToMany_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItem.Tags.ElementAt(0).Name)); + responseDocument.Data.ManyValue[0].Relationships.ShouldOnlyContainKeys("todoItem", "color"); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItem.Tags.ElementAt(1).StringId); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItem.Tags.ElementAt(1).Name)); + responseDocument.Data.ManyValue[1].Relationships.ShouldOnlyContainKeys("todoItem", "color"); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_secondary_ToOne_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(todoItem.Owner.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItem.Owner.LastName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(todoItem.Owner.DisplayName)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_empty_secondary_ToOne_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000000..7ac11df3c9 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs @@ -0,0 +1,399 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class UpdateResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public UpdateResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Tag existingTag = _fakers.Tag.Generate(); + existingTag.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "tags", + id = existingTag.StringId, + attributes = new + { + }, + relationships = new + { + } + } + }; + + string route = $"/tags/{existingTag.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Tag tagInDatabase = await dbContext.Tags.Include(tag => tag.Color).FirstWithIdAsync(existingTag.Id); + + tagInDatabase.Name.Should().Be(existingTag.Name); + tagInDatabase.Color.ShouldNotBeNull(); + tagInDatabase.Color.Id.Should().Be(existingTag.Color.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + } + + [Fact] + public async Task Can_partially_update_resource_attributes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Assignee = _fakers.Person.Generate(); + existingTodoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + + string newDescription = _fakers.TodoItem.Generate().Description; + long newDurationInHours = _fakers.TodoItem.Generate().DurationInHours!.Value; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = existingTodoItem.StringId, + attributes = new + { + description = newDescription, + durationInHours = newDurationInHours + } + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(existingTodoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newDescription)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newDurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(existingTodoItem.CreatedAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(existingTodoItem.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newDescription); + todoItemInDatabase.Priority.Should().Be(existingTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newDurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(existingTodoItem.CreatedAt); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingTodoItem.Owner.Id); + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingTodoItem.Assignee.Id); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTodoItem.Tags.ElementAt(0).Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""Description"" = @p1, ""DurationInHours"" = @p2, ""LastModifiedAt"" = @p3 +WHERE ""Id"" = @p4")); + + command.Parameters.ShouldHaveCount(4); + command.Parameters.Should().Contain("@p1", newDescription); + command.Parameters.Should().Contain("@p2", newDurationInHours); + command.Parameters.Should().Contain("@p3", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p4", existingTodoItem.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_completely_update_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Assignee = _fakers.Person.Generate(); + existingTodoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + Tag existingTag = _fakers.Tag.Generate(); + Person existingPerson1 = _fakers.Person.Generate(); + Person existingPerson2 = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTodoItem, existingTag, existingPerson1, existingPerson2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = existingTodoItem.StringId, + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson1.StringId + } + }, + assignee = new + { + data = new + { + type = "people", + id = existingPerson2.StringId + } + }, + tags = new + { + data = new[] + { + new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(existingTodoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newTodoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newTodoItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(existingTodoItem.CreatedAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(existingTodoItem.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(existingTodoItem.CreatedAt); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson1.Id); + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson2.Id); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.ShouldHaveCount(5); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""FirstName"", t3.""LastName"", t4.""Id"", t4.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" +LEFT JOIN ""Tags"" AS t4 ON t1.""Id"" = t4.""TodoItemId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""Description"" = @p1, ""Priority"" = @p2, ""DurationInHours"" = @p3, ""LastModifiedAt"" = @p4, ""OwnerId"" = @p5, ""AssigneeId"" = @p6 +WHERE ""Id"" = @p7")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", existingPerson1.Id); + command.Parameters.Should().Contain("@p6", existingPerson2.Id); + command.Parameters.Should().Contain("@p7", existingTodoItem.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingTodoItem.Tags.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItem.Tags.ElementAt(1).Id); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs new file mode 100644 index 0000000000..235ec91f2a --- /dev/null +++ b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs @@ -0,0 +1,602 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.Sql; + +public sealed class SubQueryInJoinTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SubQueryInJoinTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Join_with_table_on_ToOne_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=account"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +ORDER BY t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", t2.""Priority"", t2.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include_with_nested_sort_on_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", t2.""Description""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include_with_nested_sort_on_count() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=count(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +)")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_tables_on_includes_with_nested_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems.tags&sort[ownedTodoItems]=count(tags)&sort[ownedTodoItems.tags]=-name"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t4.""Id"", t4.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +), t4.""Name"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_tables_on_includes_with_nested_sorts_on_counts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?include=owner.ownedTodoItems.tags,owner.assignedTodoItems.tags&sort[owner.ownedTodoItems]=count(tags)&sort[owner.assignedTodoItems]=count(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"", t5.""Id"", t5.""Name"", t6.""Id"", t6.""CreatedAt"", t6.""Description"", t6.""DurationInHours"", t6.""LastModifiedAt"", t6.""Priority"", t8.""Id"", t8.""Name"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" +LEFT JOIN ""Tags"" AS t5 ON t3.""Id"" = t5.""TodoItemId"" +LEFT JOIN ""TodoItems"" AS t6 ON t2.""Id"" = t6.""OwnerId"" +LEFT JOIN ""Tags"" AS t8 ON t6.""Id"" = t8.""TodoItemId"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, ( + SELECT COUNT(*) + FROM ""Tags"" AS t4 + WHERE t3.""Id"" = t4.""TodoItemId"" +), t5.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t7 + WHERE t6.""Id"" = t7.""TodoItemId"" +), t8.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=equals(description,'X')"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Description"" = @p1 +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +ORDER BY t1.""Id"", t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter_on_has() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=has(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE EXISTS ( + SELECT 1 + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" + ) +) AS t4 ON t1.""Id"" = t4.""OwnerId"" +ORDER BY t1.""Id"", t4.""Priority"", t4.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter_on_count() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=greaterThan(count(tags),'0')"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" + ) > @p1 +) AS t4 ON t1.""Id"" = t4.""OwnerId"" +ORDER BY t1.""Id"", t4.""Priority"", t4.""LastModifiedAt"" DESC")); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 0); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_includes_with_nested_filter_and_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/people?include=ownedTodoItems.tags&filter[ownedTodoItems]=equals(description,'X')&sort[ownedTodoItems]=count(tags)&sort[ownedTodoItems.tags]=-name"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t5.""Id"", t5.""CreatedAt"", t5.""Description"", t5.""DurationInHours"", t5.""LastModifiedAt"", t5.""Priority"", t5.Id0 AS Id, t5.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"", t4.""Id"" AS Id0, t4.""Name"" + FROM ""TodoItems"" AS t2 + LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" + WHERE t2.""Description"" = @p1 +) AS t5 ON t1.""Id"" = t5.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t5.""Id"" = t3.""TodoItemId"" +), t5.""Name"" DESC")); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Join_with_nested_sub_queries_with_filters_and_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/people?include=ownedTodoItems.tags&filter[ownedTodoItems]=not(equals(description,'X'))&filter[ownedTodoItems.tags]=not(equals(name,'Y'))" + + "&sort[ownedTodoItems]=count(tags),assignee.lastName&sort[ownedTodoItems.tags]=name,-id"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t7.""Id"", t7.""CreatedAt"", t7.""Description"", t7.""DurationInHours"", t7.""LastModifiedAt"", t7.""Priority"", t7.Id00 AS Id, t7.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"", t4.""LastName"", t6.""Id"" AS Id00, t6.""Name"" + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t4 ON t2.""AssigneeId"" = t4.""Id"" + LEFT JOIN ( + SELECT t5.""Id"", t5.""Name"", t5.""TodoItemId"" + FROM ""Tags"" AS t5 + WHERE NOT (t5.""Name"" = @p2) + ) AS t6 ON t2.""Id"" = t6.""TodoItemId"" + WHERE NOT (t2.""Description"" = @p1) +) AS t7 ON t1.""Id"" = t7.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t7.""Id"" = t3.""TodoItemId"" +), t7.""LastName"", t7.""Name"", t7.Id00 DESC")); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/SqlTextAdapter.cs b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs new file mode 100644 index 0000000000..14fe65bd22 --- /dev/null +++ b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using DapperExample; + +namespace DapperTests.IntegrationTests; + +internal sealed class SqlTextAdapter +{ + private static readonly Dictionary SqlServerReplacements = new() + { + [new Regex(@"""([^""]+)""", RegexOptions.Compiled)] = "[$+]", + [new Regex($@"(VALUES \([^)]*\)){Environment.NewLine}RETURNING \[Id\]", RegexOptions.Compiled)] = $"OUTPUT INSERTED.[Id]{Environment.NewLine}$1" + }; + + private readonly DatabaseProvider _databaseProvider; + + public SqlTextAdapter(DatabaseProvider databaseProvider) + { + _databaseProvider = databaseProvider; + } + + public string Adapt(string text, bool hasClientGeneratedId) + { + string replaced = text; + + if (_databaseProvider == DatabaseProvider.MySql) + { + replaced = replaced.Replace(@"""", "`"); + + string selectInsertId = hasClientGeneratedId ? $";{Environment.NewLine}SELECT @p1" : $";{Environment.NewLine}SELECT LAST_INSERT_ID()"; + replaced = replaced.Replace($"{Environment.NewLine}RETURNING `Id`", selectInsertId); + + replaced = replaced.Replace(@"\\", @"\\\\").Replace(@" ESCAPE '\'", @" ESCAPE '\\'"); + } + else if (_databaseProvider == DatabaseProvider.SqlServer) + { + foreach ((Regex regex, string replacementPattern) in SqlServerReplacements) + { + replaced = regex.Replace(replaced, replacementPattern); + } + } + + return replaced; + } +} diff --git a/test/DapperTests/IntegrationTests/TestFakers.cs b/test/DapperTests/IntegrationTests/TestFakers.cs new file mode 100644 index 0000000000..7b66367b7c --- /dev/null +++ b/test/DapperTests/IntegrationTests/TestFakers.cs @@ -0,0 +1,61 @@ +using Bogus; +using DapperExample.Models; +using TestBuildingBlocks; +using Person = DapperExample.Models.Person; +using RgbColorType = DapperExample.Models.RgbColor; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace DapperTests.IntegrationTests; + +internal sealed class TestFakers : FakerContainer +{ + private readonly Lazy> _lazyTodoItemFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(todoItem => todoItem.Description, faker => faker.Lorem.Sentence()) + .RuleFor(todoItem => todoItem.Priority, faker => faker.Random.Enum()) + .RuleFor(todoItem => todoItem.DurationInHours, faker => faker.Random.Long(1, 250)) + .RuleFor(todoItem => todoItem.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds()) + .RuleFor(todoItem => todoItem.LastModifiedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyLoginAccountFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(loginAccount => loginAccount.UserName, faker => faker.Internet.UserName()) + .RuleFor(loginAccount => loginAccount.LastUsedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyAccountRecoveryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(accountRecovery => accountRecovery.PhoneNumber, faker => faker.Person.Phone) + .RuleFor(accountRecovery => accountRecovery.EmailAddress, faker => faker.Person.Email)); + + private readonly Lazy> _lazyPersonFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(person => person.FirstName, faker => faker.Name.FirstName()) + .RuleFor(person => person.LastName, faker => faker.Name.LastName())); + + private readonly Lazy> _lazyTagFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(tag => tag.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazyRgbColorFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(rgbColor => rgbColor.Id, faker => RgbColorType.Create(faker.Random.Byte(), faker.Random.Byte(), faker.Random.Byte()) + .Id)); + + public Faker TodoItem => _lazyTodoItemFaker.Value; + public Faker Person => _lazyPersonFaker.Value; + public Faker LoginAccount => _lazyLoginAccountFaker.Value; + public Faker AccountRecovery => _lazyAccountRecoveryFaker.Value; + public Faker Tag => _lazyTagFaker.Value; + public Faker RgbColor => _lazyRgbColorFaker.Value; +} diff --git a/test/DapperTests/UnitTests/LogicalCombinatorTests.cs b/test/DapperTests/UnitTests/LogicalCombinatorTests.cs new file mode 100644 index 0000000000..065cec7dd6 --- /dev/null +++ b/test/DapperTests/UnitTests/LogicalCombinatorTests.cs @@ -0,0 +1,49 @@ +using DapperExample.TranslationToSql.Transformations; +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Expressions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class LogicalCombinatorTests +{ + [Fact] + public void Collapses_and_filters() + { + // Arrange + var column = new ColumnInTableNode("column", ColumnType.Scalar, null); + + var conditionLeft1 = new ComparisonNode(ComparisonOperator.GreaterThan, column, new ParameterNode("@p1", 10)); + var conditionRight1 = new ComparisonNode(ComparisonOperator.LessThan, column, new ParameterNode("@p2", 20)); + var and1 = new LogicalNode(LogicalOperator.And, conditionLeft1, conditionRight1); + + var conditionLeft2 = new ComparisonNode(ComparisonOperator.GreaterOrEqual, column, new ParameterNode("@p3", 100)); + var conditionRight2 = new ComparisonNode(ComparisonOperator.LessOrEqual, column, new ParameterNode("@p4", 200)); + var and2 = new LogicalNode(LogicalOperator.And, conditionLeft2, conditionRight2); + + var conditionLeft3 = new LikeNode(column, TextMatchKind.EndsWith, "Z"); + var conditionRight3 = new LikeNode(column, TextMatchKind.StartsWith, "A"); + var and3 = new LogicalNode(LogicalOperator.And, conditionLeft3, conditionRight3); + + var source = new LogicalNode(LogicalOperator.And, and1, new LogicalNode(LogicalOperator.And, and2, and3)); + var combinator = new LogicalCombinator(); + + // Act + FilterNode result = combinator.Collapse(source); + + // Assert + IEnumerable terms = new FilterNode[] + { + conditionLeft1, + conditionRight1, + conditionLeft2, + conditionRight2, + conditionLeft3, + conditionRight3 + }.Select(condition => condition.ToString()); + + string expectedText = '(' + string.Join(") AND (", terms) + ')'; + result.ToString().Should().Be(expectedText); + } +} diff --git a/test/DapperTests/UnitTests/LogicalNodeTests.cs b/test/DapperTests/UnitTests/LogicalNodeTests.cs new file mode 100644 index 0000000000..6ce6dffab1 --- /dev/null +++ b/test/DapperTests/UnitTests/LogicalNodeTests.cs @@ -0,0 +1,22 @@ +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Expressions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class LogicalNodeTests +{ + [Fact] + public void Throws_on_insufficient_terms() + { + // Arrange + var filter = new ComparisonNode(ComparisonOperator.Equals, new ParameterNode("@p1", null), new ParameterNode("@p2", null)); + + // Act + Action action = () => _ = new LogicalNode(LogicalOperator.And, filter); + + // Assert + action.Should().ThrowExactly().WithMessage("At least two terms are required.*"); + } +} diff --git a/test/DapperTests/UnitTests/ParameterNodeTests.cs b/test/DapperTests/UnitTests/ParameterNodeTests.cs new file mode 100644 index 0000000000..497e72d447 --- /dev/null +++ b/test/DapperTests/UnitTests/ParameterNodeTests.cs @@ -0,0 +1,45 @@ +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class ParameterNodeTests +{ + [Fact] + public void Throws_on_invalid_name() + { + // Act + Action action = () => _ = new ParameterNode("p1", null); + + // Assert + action.Should().ThrowExactly().WithMessage("Parameter name must start with an '@' symbol and not be empty.*"); + } + + [Theory] + [InlineData(null, "null")] + [InlineData(-123, "-123")] + [InlineData(123U, "123")] + [InlineData(-123L, "-123")] + [InlineData(123UL, "123")] + [InlineData((short)-123, "-123")] + [InlineData((ushort)123, "123")] + [InlineData('A', "'A'")] + [InlineData((sbyte)123, "123")] + [InlineData((byte)123, "0x7B")] + [InlineData(1.23F, "1.23")] + [InlineData(1.23D, "1.23")] + [InlineData("123", "'123'")] + [InlineData(DayOfWeek.Saturday, "DayOfWeek.Saturday")] + public void Can_format_parameter(object? parameterValue, string formattedValueExpected) + { + // Arrange + var parameter = new ParameterNode("@name", parameterValue); + + // Act + string text = parameter.ToString(); + + // Assert + text.Should().Be("@name = " + formattedValueExpected); + } +} diff --git a/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs new file mode 100644 index 0000000000..aadedc8c85 --- /dev/null +++ b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs @@ -0,0 +1,54 @@ +using DapperExample; +using DapperExample.TranslationToSql.DataModel; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class RelationshipForeignKeyTests +{ + private readonly IResourceGraph _resourceGraph = + new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); + + [Fact] + public void Can_format_foreign_key_for_ToOne_relationship() + { + // Arrange + RelationshipAttribute parentRelationship = _resourceGraph.GetResourceType().GetRelationshipByPropertyName(nameof(TestResource.Parent)); + + // Act + var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, parentRelationship, true, "ParentId", true); + + // Assert + foreignKey.ToString().Should().Be(@"TestResource.Parent => ""TestResources"".""ParentId""?"); + } + + [Fact] + public void Can_format_foreign_key_for_ToMany_relationship() + { + // Arrange + RelationshipAttribute childrenRelationship = + _resourceGraph.GetResourceType().GetRelationshipByPropertyName(nameof(TestResource.Children)); + + // Act + var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, childrenRelationship, false, "TestResourceId", false); + + // Assert + foreignKey.ToString().Should().Be(@"TestResource.Children => ""TestResources"".""TestResourceId"""); + } + + [UsedImplicitly] + private sealed class TestResource : Identifiable + { + [HasOne] + public TestResource? Parent { get; set; } + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } +} diff --git a/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs new file mode 100644 index 0000000000..394cdecc5b --- /dev/null +++ b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using DapperExample.TranslationToSql; +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class SqlTreeNodeVisitorTests +{ + [Fact] + public void Visitor_methods_call_default_visit() + { + // Arrange + var visitor = new TestVisitor(); + + MethodInfo[] visitMethods = visitor.GetType().GetMethods() + .Where(method => method.Name.StartsWith("Visit", StringComparison.Ordinal) && method.Name != "Visit").ToArray(); + + object?[] parameters = + { + null, + null + }; + + // Act + foreach (MethodInfo method in visitMethods) + { + _ = method.Invoke(visitor, parameters); + } + + visitor.HitCount.Should().Be(26); + } + + private sealed class TestVisitor : SqlTreeNodeVisitor + { + public int HitCount { get; private set; } + + public override object? DefaultVisit(SqlTreeNode node, object? argument) + { + HitCount++; + return base.DefaultVisit(node, argument); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index d2163d95cd..ac1991ce4d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -503,7 +503,7 @@ public async Task Uses_default_page_number_and_size() Blog blog = _fakers.Blog.Generate(); blog.Posts = _fakers.BlogPost.Generate(3); - blog.Posts.ToList().ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet()); + blog.Posts.ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet()); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index fcae662098..6036bfc6d4 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs @@ -43,7 +43,7 @@ public sealed class FieldChainPatternInheritanceMatchTests public FieldChainPatternInheritanceMatchTests(ITestOutputHelper testOutputHelper) { - var loggerProvider = new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message); + var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); var options = new JsonApiOptions(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 964cf6fb8c..052ec4a44f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -29,7 +29,7 @@ public sealed class FieldChainPatternMatchTests public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) { - var loggerProvider = new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message); + var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); var options = new JsonApiOptions(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs index 66b6dd0c81..34ca2ef259 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs @@ -133,7 +133,139 @@ public void Cannot_set_value_to_collection_with_primitive_element() action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); } - private sealed class TestResource : Identifiable + [Fact] + public void Can_add_value_to_List() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource + { + Children = new List + { + new() + } + }; + + var resourceToAdd = new TestResource(); + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + List collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(2); + } + + [Fact] + public void Can_add_existing_value_to_List() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resourceToAdd = new TestResource(); + + var resource = new TestResource + { + Children = new List + { + resourceToAdd + } + }; + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + List collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(1); + } + + [Fact] + public void Can_add_value_to_HashSet() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource + { + Children = new HashSet + { + new() + } + }; + + var resourceToAdd = new TestResource(); + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + HashSet collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(2); + } + + [Fact] + public void Can_add_existing_value_to_HashSet() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resourceToAdd = new TestResource(); + + var resource = new TestResource + { + Children = new HashSet + { + resourceToAdd + } + }; + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + HashSet collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(1); + } + + [Fact] + public void Can_add_value_to_null_collection() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource + { + Children = null! + }; + + var resourceToAdd = new TestResource(); + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + HashSet collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(1); + } + + public sealed class TestResource : Identifiable { [HasMany] public IEnumerable Children { get; set; } = new HashSet(); diff --git a/test/TestBuildingBlocks/CollectionExtensions.cs b/test/TestBuildingBlocks/CollectionExtensions.cs new file mode 100644 index 0000000000..a07c93ddd9 --- /dev/null +++ b/test/TestBuildingBlocks/CollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace TestBuildingBlocks; + +public static class CollectionExtensions +{ + public static void ForEach(this IEnumerable source, Action action) + { + foreach (T element in source) + { + action(element); + } + } +} diff --git a/test/TestBuildingBlocks/XUnitLoggerProvider.cs b/test/TestBuildingBlocks/XUnitLoggerProvider.cs index 2aafd4d396..e19f8cbbc6 100644 --- a/test/TestBuildingBlocks/XUnitLoggerProvider.cs +++ b/test/TestBuildingBlocks/XUnitLoggerProvider.cs @@ -1,6 +1,7 @@ using System.Text; using JsonApiDotNetCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Xunit.Abstractions; namespace TestBuildingBlocks; @@ -10,18 +11,27 @@ public sealed class XUnitLoggerProvider : ILoggerProvider { private readonly ITestOutputHelper _testOutputHelper; private readonly LogOutputFields _outputFields; + private readonly string? _categoryPrefixFilter; - public XUnitLoggerProvider(ITestOutputHelper testOutputHelper, LogOutputFields outputFields = LogOutputFields.All) + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper, string? categoryPrefixFilter, LogOutputFields outputFields = LogOutputFields.All) { ArgumentGuard.NotNull(testOutputHelper); _testOutputHelper = testOutputHelper; + _categoryPrefixFilter = categoryPrefixFilter; _outputFields = outputFields; } public ILogger CreateLogger(string categoryName) { - return new XUnitLogger(_testOutputHelper, _outputFields, categoryName); + ArgumentGuard.NotNull(categoryName); + + if (_categoryPrefixFilter == null || categoryName.StartsWith(_categoryPrefixFilter, StringComparison.Ordinal)) + { + return new XUnitLogger(_testOutputHelper, _outputFields, categoryName); + } + + return NullLogger.Instance; } public void Dispose() @@ -33,13 +43,9 @@ private sealed class XUnitLogger : ILogger private readonly ITestOutputHelper _testOutputHelper; private readonly LogOutputFields _outputFields; private readonly string _categoryName; - private readonly IExternalScopeProvider _scopeProvider = new NoExternalScopeProvider(); public XUnitLogger(ITestOutputHelper testOutputHelper, LogOutputFields outputFields, string categoryName) { - ArgumentGuard.NotNull(testOutputHelper); - ArgumentGuard.NotNull(categoryName); - _testOutputHelper = testOutputHelper; _outputFields = outputFields; _categoryName = categoryName; @@ -57,7 +63,7 @@ public bool IsEnabled(LogLevel logLevel) public IDisposable BeginScope(TState state) where TState : notnull { - return _scopeProvider.Push(state); + return EmptyDisposable.Instance; } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) @@ -95,19 +101,10 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (exception != null && _outputFields.HasFlag(LogOutputFields.Exception)) { - builder.Append('\n'); + builder.Append(Environment.NewLine); builder.Append(exception); } - if (_outputFields.HasFlag(LogOutputFields.Scopes)) - { - _scopeProvider.ForEachScope((scope, nextState) => - { - nextState.Append("\n => "); - nextState.Append(scope); - }, builder); - } - try { _testOutputHelper.WriteLine(builder.ToString()); @@ -133,24 +130,12 @@ private static string GetLogLevelString(LogLevel logLevel) }; } - private sealed class NoExternalScopeProvider : IExternalScopeProvider + private sealed class EmptyDisposable : IDisposable { - public void ForEachScope(Action callback, TState state) - { - } - - public IDisposable Push(object? state) - { - return EmptyDisposable.Instance; - } + public static EmptyDisposable Instance { get; } = new(); - private sealed class EmptyDisposable : IDisposable + public void Dispose() { - public static EmptyDisposable Instance { get; } = new(); - - public void Dispose() - { - } } } } From a3dd822e403952d598ccd5b90ff2fca4fb166267 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Oct 2023 01:21:42 +0000 Subject: [PATCH 25/53] Bump docfx from 2.71.1 to 2.72.1 (#1377) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e0d4746bdb..a87047dffd 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "docfx": { - "version": "2.71.1", + "version": "2.72.1", "commands": [ "docfx" ] From 8c6c6059a3fc89ce81cb2bbf6badb68f0754dd07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:32:48 +0000 Subject: [PATCH 26/53] Update JetBrains.Annotations requirement from 2023.2.* to 2023.3.* (#1383) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 14cee715e8..e9b83f5986 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,7 +34,7 @@ 6.12.* 2.3.* 1.3.* - 2023.2.* + 2023.3.* 7.0.* 1.1.* 7.0.* From 0bf35b6d22db5ba2d3ce8f922e1b20d0c27e3806 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:59:14 +0000 Subject: [PATCH 27/53] Bump jetbrains.resharper.globaltools from 2023.2.2 to 2023.2.3 (#1380) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index a87047dffd..6dc2459ae5 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2023.2.2", + "version": "2023.2.3", "commands": [ "jb" ] From 24b9546e0fd77566b00102e3bd85e3970e09eda3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Nov 2023 17:28:05 +0000 Subject: [PATCH 28/53] Update Microsoft.NET.Test.Sdk requirement from 17.7.* to 17.8.* (#1385) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e9b83f5986..910b387f01 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -38,7 +38,7 @@ 7.0.* 1.1.* 7.0.* - 17.7.* + 17.8.* 2.5.* From ce071722632dad3775b0339bf3473b27b0bcc7d2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 16 Nov 2023 05:35:52 +0100 Subject: [PATCH 29/53] Fix crash on atomic:operations requests when trace logging is turned on --- .../Middleware/TraceLogWriter.cs | 105 ++++-- src/JsonApiDotNetCore/Queries/QueryLayer.cs | 2 +- .../Mixed/AtomicLoggingTests.cs | 10 +- .../Mixed/AtomicTraceLoggingTests.cs | 318 ++++++++++++++++++ .../ApiControllerAttributeLogTests.cs | 8 +- .../ExceptionHandlerTests.cs | 19 +- .../IntegrationTests/Logging/LoggingTests.cs | 20 +- .../ResourceGraphBuilderTests.cs | 25 +- test/TestBuildingBlocks/FakeLogMessage.cs | 22 ++ test/TestBuildingBlocks/FakeLoggerFactory.cs | 47 +-- .../IgnoreLineEndingsComparer.cs | 36 ++ 11 files changed, 520 insertions(+), 92 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs create mode 100644 test/TestBuildingBlocks/FakeLogMessage.cs create mode 100644 test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index e00bbd50a8..e451091863 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -4,6 +4,9 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Middleware; @@ -14,8 +17,69 @@ internal abstract class TraceLogWriter { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - ReferenceHandler = ReferenceHandler.Preserve + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new JsonStringEnumConverter(), + new ResourceTypeInTraceJsonConverter(), + new ResourceFieldInTraceJsonConverterFactory(), + new IdentifiableInTraceJsonConverter() + } }; + + private sealed class ResourceTypeInTraceJsonConverter : JsonConverter + { + public override ResourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, ResourceType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } + + private sealed class ResourceFieldInTraceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(ResourceFieldAttribute)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new ResourceFieldInTraceJsonConverter(); + } + + private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + { + public override ResourceFieldAttribute Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, ResourceFieldAttribute value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } + } + + private sealed class IdentifiableInTraceJsonConverter : JsonConverter + { + public override IIdentifiable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, IIdentifiable value, JsonSerializerOptions options) + { + Type runtimeType = value.GetType(); + JsonSerializer.Serialize(writer, value, runtimeType, options); + } + } } internal sealed class TraceLogWriter : TraceLogWriter @@ -88,26 +152,12 @@ private static void WriteProperty(StringBuilder builder, PropertyInfo property, builder.Append(": "); object? value = property.GetValue(instance); - - if (value == null) - { - builder.Append("null"); - } - else if (value is string stringValue) - { - builder.Append('"'); - builder.Append(stringValue); - builder.Append('"'); - } - else - { - WriteObject(builder, value); - } + WriteObject(builder, value); } - private static void WriteObject(StringBuilder builder, object value) + private static void WriteObject(StringBuilder builder, object? value) { - if (HasToStringOverload(value.GetType())) + if (value != null && value is not string && HasToStringOverload(value.GetType())) { builder.Append(value); } @@ -118,28 +168,19 @@ private static void WriteObject(StringBuilder builder, object value) } } - private static bool HasToStringOverload(Type? type) + private static bool HasToStringOverload(Type type) { - if (type != null) - { - MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); - - if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) - { - return true; - } - } - - return false; + MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); + return toStringMethod != null && toStringMethod.DeclaringType != typeof(object); } - private static string SerializeObject(object value) + private static string SerializeObject(object? value) { try { return JsonSerializer.Serialize(value, SerializerOptions); } - catch (JsonException) + catch (Exception exception) when (exception is JsonException or NotSupportedException) { // Never crash as a result of logging, this is best-effort only. return "object"; diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index 95d61fd4b8..3a755d519b 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -42,7 +42,7 @@ internal void WriteLayer(IndentingStringWriter writer, string? prefix) using (writer.Indent()) { - if (Include != null) + if (Include != null && Include.Elements.Any()) { writer.WriteLine($"{nameof(Include)}: {Include}"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index d59cd3d8b2..bae5abf988 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -80,9 +80,10 @@ public async Task Logs_at_error_level_on_unhandled_exception() error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); } @@ -117,9 +118,10 @@ public async Task Logs_at_info_level_on_invalid_request_body() responseDocument.Errors.ShouldHaveCount(1); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body", StringComparison.Ordinal)); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs new file mode 100644 index 0000000000..d9c315513d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -0,0 +1,318 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; + +public sealed class AtomicTraceLoggingTests : IClassFixture, OperationsDbContext>> +{ + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicTraceLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); + + testContext.ConfigureLogging(options => + { + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, _) => category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); + }); + + testContext.ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + }); + } + + [Fact] + public async Task Logs_execution_flow_at_trace_level_on_operations_request() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + string newGenre = _fakers.MusicTrack.Generate().Genre!; + + Lyric existingLyric = _fakers.Lyric.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + Performer existingPerformer = _fakers.Performer.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingLyric, existingCompany, existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + genre = newGenre + }, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + }, + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + }, + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + + logLines.Should().BeEquivalentTo(new[] + { + $@"[TRACE] Received POST request at 'http://localhost/operations' with body: <<{{ + ""atomic:operations"": [ + {{ + ""op"": ""update"", + ""data"": {{ + ""type"": ""musicTracks"", + ""id"": ""{existingTrack.StringId}"", + ""attributes"": {{ + ""genre"": ""{newGenre}"" + }}, + ""relationships"": {{ + ""lyric"": {{ + ""data"": {{ + ""type"": ""lyrics"", + ""id"": ""{existingLyric.StringId}"" + }} + }}, + ""ownedBy"": {{ + ""data"": {{ + ""type"": ""recordCompanies"", + ""id"": ""{existingCompany.StringId}"" + }} + }}, + ""performers"": {{ + ""data"": [ + {{ + ""type"": ""performers"", + ""id"": ""{existingPerformer.StringId}"" + }} + ] + }} + }} + }} + }} + ] +}}>>", + $@"[TRACE] Entering PostOperationsAsync(operations: [ + {{ + ""Resource"": {{ + ""Id"": ""{existingTrack.StringId}"", + ""Genre"": ""{newGenre}"", + ""ReleasedAt"": ""0001-01-01T00:00:00+00:00"", + ""Lyric"": {{ + ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingLyric.Id}, + ""StringId"": ""{existingLyric.StringId}"" + }}, + ""OwnedBy"": {{ + ""Tracks"": [], + ""Id"": {existingCompany.Id}, + ""StringId"": ""{existingCompany.StringId}"" + }}, + ""Performers"": [ + {{ + ""BornAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingPerformer.Id}, + ""StringId"": ""{existingPerformer.StringId}"" + }} + ], + ""OccursIn"": [], + ""StringId"": ""{existingTrack.StringId}"" + }}, + ""TargetedFields"": {{ + ""Attributes"": [ + ""genre"" + ], + ""Relationships"": [ + ""lyric"", + ""ownedBy"", + ""performers"" + ] + }}, + ""Request"": {{ + ""Kind"": ""AtomicOperations"", + ""PrimaryId"": ""{existingTrack.StringId}"", + ""PrimaryResourceType"": ""musicTracks"", + ""IsCollection"": false, + ""IsReadOnly"": false, + ""WriteOperation"": ""UpdateResource"" + }} + }} +])", + $@"[TRACE] Entering UpdateAsync(id: {existingTrack.StringId}, resource: {{ + ""Id"": ""{existingTrack.StringId}"", + ""Genre"": ""{newGenre}"", + ""ReleasedAt"": ""0001-01-01T00:00:00+00:00"", + ""Lyric"": {{ + ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingLyric.Id}, + ""StringId"": ""{existingLyric.StringId}"" + }}, + ""OwnedBy"": {{ + ""Tracks"": [], + ""Id"": {existingCompany.Id}, + ""StringId"": ""{existingCompany.StringId}"" + }}, + ""Performers"": [ + {{ + ""BornAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingPerformer.Id}, + ""StringId"": ""{existingPerformer.StringId}"" + }} + ], + ""OccursIn"": [], + ""StringId"": ""{existingTrack.StringId}"" +}})", + $@"[TRACE] Entering GetForUpdateAsync(queryLayer: QueryLayer +{{ + Include: lyric,ownedBy,performers + Filter: equals(id,'{existingTrack.StringId}') +}} +)", + $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer +{{ + Include: lyric,ownedBy,performers + Filter: equals(id,'{existingTrack.StringId}') +}} +)", + $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer +{{ + Include: lyric,ownedBy,performers + Filter: equals(id,'{existingTrack.StringId}') +}} +)", + $@"[TRACE] Entering UpdateAsync(resourceFromRequest: {{ + ""Id"": ""{existingTrack.StringId}"", + ""Genre"": ""{newGenre}"", + ""ReleasedAt"": ""0001-01-01T00:00:00+00:00"", + ""Lyric"": {{ + ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingLyric.Id}, + ""StringId"": ""{existingLyric.StringId}"" + }}, + ""OwnedBy"": {{ + ""Tracks"": [], + ""Id"": {existingCompany.Id}, + ""StringId"": ""{existingCompany.StringId}"" + }}, + ""Performers"": [ + {{ + ""BornAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingPerformer.Id}, + ""StringId"": ""{existingPerformer.StringId}"" + }} + ], + ""OccursIn"": [], + ""StringId"": ""{existingTrack.StringId}"" +}}, resourceFromDatabase: {{ + ""Id"": ""{existingTrack.StringId}"", + ""Title"": ""{existingTrack.Title}"", + ""LengthInSeconds"": {JsonSerializer.Serialize(existingTrack.LengthInSeconds)}, + ""Genre"": ""{existingTrack.Genre}"", + ""ReleasedAt"": {JsonSerializer.Serialize(existingTrack.ReleasedAt)}, + ""Lyric"": {{ + ""Format"": ""{existingTrack.Lyric.Format}"", + ""Text"": {JsonSerializer.Serialize(existingTrack.Lyric.Text)}, + ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", + ""Id"": {existingTrack.Lyric.Id}, + ""StringId"": ""{existingTrack.Lyric.StringId}"" + }}, + ""OwnedBy"": {{ + ""Name"": ""{existingTrack.OwnedBy.Name}"", + ""CountryOfResidence"": ""{existingTrack.OwnedBy.CountryOfResidence}"", + ""Tracks"": [ + null + ], + ""Id"": {existingTrack.OwnedBy.Id}, + ""StringId"": ""{existingTrack.OwnedBy.StringId}"" + }}, + ""Performers"": [ + {{ + ""ArtistName"": ""{existingTrack.Performers[0].ArtistName}"", + ""BornAt"": {JsonSerializer.Serialize(existingTrack.Performers[0].BornAt)}, + ""Id"": {existingTrack.Performers[0].Id}, + ""StringId"": ""{existingTrack.Performers[0].StringId}"" + }} + ], + ""OccursIn"": [], + ""StringId"": ""{existingTrack.StringId}"" +}})", + $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingTrack.StringId}') +}} +)", + $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingTrack.StringId}') +}} +)" + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs index dce62dec7e..949ea5da3b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -38,10 +38,10 @@ public void Logs_warning_at_startup_when_ApiControllerAttribute_found() _ = Factory; // Assert - _loggerFactory.Logger.Messages.ShouldHaveCount(1); - _loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); + IReadOnlyList logLines = _loggerFactory.Logger.GetLines(); + logLines.ShouldHaveCount(1); - _loggerFactory.Logger.Messages.Single().Text.Should().Be( - $"Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance."); + logLines[0].Should().Be( + $"[WARNING] Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 7bf804a7d0..3641e9e23d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -80,9 +80,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldHaveCount(1); + + logMessages[0].LogLevel.Should().Be(LogLevel.Warning); + logMessages[0].Text.Should().Contain("Article with code 'X123' is no longer available."); } [Fact] @@ -123,7 +125,8 @@ public async Task Logs_and_produces_error_response_on_deserialization_failure() stackTraceLines.ShouldNotBeEmpty(); }); - loggerFactory.Logger.Messages.Should().BeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.Should().BeEmpty(); } [Fact] @@ -166,8 +169,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldHaveCount(1); + + logMessages[0].LogLevel.Should().Be(LogLevel.Error); + logMessages[0].Text.Should().Contain("Exception has been thrown by the target of an invocation."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index 3b92994de9..56818ff08f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -64,10 +64,11 @@ public async Task Logs_request_body_at_Trace_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + logLines.Should().ContainSingle(line => + line.StartsWith("[TRACE] Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] @@ -86,10 +87,11 @@ public async Task Logs_response_body_at_Trace_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + logLines.Should().ContainSingle(line => + line.StartsWith("[TRACE] Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] @@ -110,9 +112,9 @@ public async Task Logs_invalid_request_body_error_at_Information_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body.")); + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body.")); } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 97a35603b3..11ae36cd72 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -263,11 +263,11 @@ public void Logs_warning_when_adding_non_resource_type() builder.Add(typeof(NonResource)); // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldHaveCount(1); - FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); - message.LogLevel.Should().Be(LogLevel.Warning); - message.Text.Should().Be($"Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'. Add [NoResource] to suppress this warning."); + logLines[0].Should().Be( + $"[WARNING] Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'. Add [NoResource] to suppress this warning."); } [Fact] @@ -282,7 +282,8 @@ public void Logs_no_warning_when_adding_non_resource_type_with_suppression() builder.Add(typeof(NonResourceWithSuppression)); // Assert - loggerFactory.Logger.Messages.Should().BeEmpty(); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.Should().BeEmpty(); } [Fact] @@ -297,11 +298,10 @@ public void Logs_warning_when_adding_resource_without_attributes() builder.Add(); // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldHaveCount(1); - FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); - message.LogLevel.Should().Be(LogLevel.Warning); - message.Text.Should().Be($"Type '{typeof(ResourceWithHasOneRelationship)}' does not contain any attributes."); + logLines[0].Should().Be($"[WARNING] Type '{typeof(ResourceWithHasOneRelationship)}' does not contain any attributes."); } [Fact] @@ -316,11 +316,10 @@ public void Logs_warning_on_empty_graph() builder.Build(); // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldHaveCount(1); - FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); - message.LogLevel.Should().Be(LogLevel.Warning); - message.Text.Should().Be("The resource graph is empty."); + logLines[0].Should().Be("[WARNING] The resource graph is empty."); } [Fact] diff --git a/test/TestBuildingBlocks/FakeLogMessage.cs b/test/TestBuildingBlocks/FakeLogMessage.cs new file mode 100644 index 0000000000..8df3eebde6 --- /dev/null +++ b/test/TestBuildingBlocks/FakeLogMessage.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; + +namespace TestBuildingBlocks; + +[PublicAPI] +public sealed class FakeLogMessage +{ + public LogLevel LogLevel { get; } + public string Text { get; } + + public FakeLogMessage(LogLevel logLevel, string text) + { + LogLevel = logLevel; + Text = text; + } + + public override string ToString() + { + return $"[{LogLevel.ToString().ToUpperInvariant()}] {Text}"; + } +} diff --git a/test/TestBuildingBlocks/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs index 1a1ac6d402..c946ede4ed 100644 --- a/test/TestBuildingBlocks/FakeLoggerFactory.cs +++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using JetBrains.Annotations; using Microsoft.Extensions.Logging; @@ -30,9 +29,9 @@ public void Dispose() public sealed class FakeLogger : ILogger { private readonly LogLevel _minimumLevel; - private readonly ConcurrentBag _messages = new(); - public IReadOnlyCollection Messages => _messages; + private readonly object _lockObject = new(); + private readonly List _messages = new(); public FakeLogger(LogLevel minimumLevel) { @@ -46,7 +45,10 @@ public bool IsEnabled(LogLevel logLevel) public void Clear() { - _messages.Clear(); + lock (_lockObject) + { + _messages.Clear(); + } } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) @@ -54,7 +56,11 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (IsEnabled(logLevel)) { string message = formatter(state, exception); - _messages.Add(new FakeLogMessage(logLevel, message)); + + lock (_lockObject) + { + _messages.Add(new FakeLogMessage(logLevel, message)); + } } } @@ -64,6 +70,20 @@ public IDisposable BeginScope(TState state) return NullScope.Instance; } + public IReadOnlyList GetMessages() + { + lock (_lockObject) + { + List snapshot = _messages.ToList(); + return snapshot.AsReadOnly(); + } + } + + public IReadOnlyList GetLines() + { + return GetMessages().Select(message => message.ToString()).ToArray(); + } + private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); @@ -77,21 +97,4 @@ public void Dispose() } } } - - public sealed class FakeLogMessage - { - public LogLevel LogLevel { get; } - public string Text { get; } - - public FakeLogMessage(LogLevel logLevel, string text) - { - LogLevel = logLevel; - Text = text; - } - - public override string ToString() - { - return $"[{LogLevel.ToString().ToUpperInvariant()}] {Text}"; - } - } } diff --git a/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs new file mode 100644 index 0000000000..2d6886e00c --- /dev/null +++ b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs @@ -0,0 +1,36 @@ +namespace TestBuildingBlocks; + +public sealed class IgnoreLineEndingsComparer : IEqualityComparer +{ + private static readonly string[] LineSeparators = + { + "\r\n", + "\r", + "\n" + }; + + public static readonly IgnoreLineEndingsComparer Instance = new(); + + public bool Equals(string? x, string? y) + { + if (x == y) + { + return true; + } + + if (x == null || y == null) + { + return false; + } + + string[] xLines = x.Split(LineSeparators, StringSplitOptions.None); + string[] yLines = y.Split(LineSeparators, StringSplitOptions.None); + + return xLines.SequenceEqual(yLines); + } + + public int GetHashCode(string obj) + { + return obj.GetHashCode(); + } +} From 6d3745b8c42a0355c9b9f1bd7bc261f62dd530ac Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:47:47 +0100 Subject: [PATCH 30/53] Fixed crash when using System.Text.Json v6.0 --- src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs | 14 ++++++++++---- .../SingleOrManyDataConverterFactory.cs | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index e451091863..348b61cec1 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -50,17 +51,22 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new ResourceFieldInTraceJsonConverter(); + Type objectType = typeof(ResourceFieldInTraceJsonConverter<>); + Type converterType = objectType.MakeGenericType(typeToConvert); + + return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, + CultureInfo.InvariantCulture)!; } - private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + where TField : ResourceFieldAttribute { - public override ResourceFieldAttribute Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override TField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotSupportedException(); } - public override void Write(Utf8JsonWriter writer, ResourceFieldAttribute value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, TField value, JsonSerializerOptions options) { writer.WriteStringValue(value.PublicName); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index ccf33fcd2f..76eebabf7b 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; @@ -24,7 +25,7 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer Type objectType = typeToConvert.GetGenericArguments()[0]; Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; + return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, CultureInfo.InvariantCulture)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> From 43ed74171a1829e58d1a6c0cf11d6d53d1a1e580 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:39:32 +0100 Subject: [PATCH 31/53] Fix trace logging for resource inheritance --- .../Repositories/ResourceChangeDetector.cs | 2 +- .../DataModel/BaseDataModelService.cs | 4 +- .../Middleware/TraceLogWriter.cs | 42 +++- .../SingleOrManyDataConverterFactory.cs | 4 +- .../IntegrationTests/Logging/Banana.cs | 14 ++ .../IntegrationTests/Logging/Fruit.cs | 13 ++ .../IntegrationTests/Logging/FruitBowl.cs | 13 ++ .../Logging/LoggingDbContext.cs | 4 + .../IntegrationTests/Logging/LoggingFakers.cs | 10 + .../IntegrationTests/Logging/LoggingTests.cs | 203 ++++++++++++++++++ .../IntegrationTests/Logging/Peach.cs | 14 ++ 11 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs index 22da724ae2..bd6537b547 100644 --- a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -209,7 +209,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) { Type declaredType = resourceType.ClrType; - Type instanceType = resource.GetType(); + Type instanceType = resource.GetClrType(); if (instanceType != declaredType) { diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs index 607d8dc080..c964b6b3e7 100644 --- a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs @@ -139,7 +139,7 @@ private static bool IsMapped(PropertyInfo property) return null; } - PropertyInfo rightKeyProperty = rightResource.GetType().GetProperty(TableSourceNode.IdColumnName)!; + PropertyInfo rightKeyProperty = rightResource.GetClrType().GetProperty(TableSourceNode.IdColumnName)!; return rightKeyProperty.GetValue(rightResource); } @@ -150,7 +150,7 @@ private static bool IsMapped(PropertyInfo property) private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) { Type declaredType = resourceType.ClrType; - Type instanceType = resource.GetType(); + Type instanceType = resource.GetClrType(); if (instanceType != declaredType) { diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index 348b61cec1..0b20b897d1 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -25,6 +24,7 @@ internal abstract class TraceLogWriter new JsonStringEnumConverter(), new ResourceTypeInTraceJsonConverter(), new ResourceFieldInTraceJsonConverterFactory(), + new AbstractResourceWrapperInTraceJsonConverterFactory(), new IdentifiableInTraceJsonConverter() } }; @@ -51,11 +51,8 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - Type objectType = typeof(ResourceFieldInTraceJsonConverter<>); - Type converterType = objectType.MakeGenericType(typeToConvert); - - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, - CultureInfo.InvariantCulture)!; + Type converterType = typeof(ResourceFieldInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; } private sealed class ResourceFieldInTraceJsonConverter : JsonConverter @@ -82,10 +79,43 @@ public override IIdentifiable Read(ref Utf8JsonReader reader, Type typeToConvert public override void Write(Utf8JsonWriter writer, IIdentifiable value, JsonSerializerOptions options) { + // Intentionally *not* calling GetClrType() because we need delegation to the wrapper converter. Type runtimeType = value.GetType(); + JsonSerializer.Serialize(writer, value, runtimeType, options); } } + + private sealed class AbstractResourceWrapperInTraceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(IAbstractResourceWrapper)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + Type converterType = typeof(AbstractResourceWrapperInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class AbstractResourceWrapperInTraceJsonConverter : JsonConverter + where TWrapper : IAbstractResourceWrapper + { + public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("ClrType", value.AbstractType.FullName); + writer.WriteString("StringId", value.StringId); + writer.WriteEndObject(); + } + } + } } internal sealed class TraceLogWriter : TraceLogWriter diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index 76eebabf7b..457f5082ef 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -1,5 +1,3 @@ -using System.Globalization; -using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -25,7 +23,7 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer Type objectType = typeToConvert.GetGenericArguments()[0]; Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, CultureInfo.InvariantCulture)!; + return (JsonConverter)Activator.CreateInstance(converterType)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs new file mode 100644 index 0000000000..6b1ad732d0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class Banana : Fruit +{ + public override string Color => "Yellow"; + + [Attr] + public double LengthInCentimeters { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs new file mode 100644 index 0000000000..fd0fbf0dfa --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public abstract class Fruit : Identifiable +{ + [Attr] + public abstract string Color { get; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs new file mode 100644 index 0000000000..15cae39fe9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class FruitBowl : Identifiable +{ + [HasMany] + public ISet Fruits { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs index 761806d3c9..26d86c8c3f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs @@ -8,6 +8,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; public sealed class LoggingDbContext : TestableDbContext { public DbSet AuditEntries => Set(); + public DbSet FruitBowls => Set(); + public DbSet Fruits => Set(); + public DbSet Bananas => Set(); + public DbSet Peaches => Set(); public LoggingDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index 5d2b25a74c..a52f164bf0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -13,5 +13,15 @@ internal sealed class LoggingFakers : FakerContainer .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyBananaFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(banana => banana.LengthInCentimeters, faker => faker.Random.Double(10, 25))); + + private readonly Lazy> _lazyPeachFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(peach => peach.DiameterInCentimeters, faker => faker.Random.Double(6, 7.5))); + public Faker AuditEntry => _lazyAuditEntryFaker.Value; + public Faker Banana => _lazyBananaFaker.Value; + public Faker Peach => _lazyPeachFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index 56818ff08f..8cb79e0376 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -17,6 +18,7 @@ public LoggingTests(IntegrationTestContext, Lo _testContext = testContext; testContext.UseController(); + testContext.UseController(); var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); @@ -25,6 +27,7 @@ public LoggingTests(IntegrationTestContext, Lo options.ClearProviders(); options.AddProvider(loggerFactory); options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, _) => category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); }); testContext.ConfigureServices(services => @@ -117,4 +120,204 @@ public async Task Logs_invalid_request_body_error_at_Information_level() logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body.")); } + + [Fact] + public async Task Logs_method_parameters_of_abstract_resource_type_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var existingBowl = new FruitBowl(); + Banana existingBanana = _fakers.Banana.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(existingBowl); + dbContext.Fruits.Add(existingBanana); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "fruits", + id = existingBanana.StringId + } + } + }; + + string route = $"/fruitBowls/{existingBowl.StringId}/relationships/fruits"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + + logLines.Should().BeEquivalentTo(new[] + { + $@"[TRACE] Received POST request at 'http://localhost/fruitBowls/{existingBowl.StringId}/relationships/fruits' with body: <<{{ + ""data"": [ + {{ + ""type"": ""fruits"", + ""id"": ""{existingBanana.StringId}"" + }} + ] +}}>>", + $@"[TRACE] Entering PostRelationshipAsync(id: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""ClrType"": ""{typeof(Fruit).FullName}"", + ""StringId"": ""{existingBanana.StringId}"" + }} +])", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftId: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""ClrType"": ""{typeof(Fruit).FullName}"", + ""StringId"": ""{existingBanana.StringId}"" + }} +])", + $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingBanana.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingBanana.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {existingBowl.Id}, rightResourceIds: [ + {{ + ""Color"": ""Yellow"", + ""LengthInCentimeters"": {existingBanana.LengthInCentimeters.ToString(CultureInfo.InvariantCulture)}, + ""Id"": {existingBanana.Id}, + ""StringId"": ""{existingBanana.StringId}"" + }} +])" + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } + + [Fact] + public async Task Logs_method_parameters_of_concrete_resource_type_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var existingBowl = new FruitBowl(); + Peach existingPeach = _fakers.Peach.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(existingBowl); + dbContext.Fruits.Add(existingPeach); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "peaches", + id = existingPeach.StringId + } + } + }; + + string route = $"/fruitBowls/{existingBowl.StringId}/relationships/fruits"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + + logLines.Should().BeEquivalentTo(new[] + { + $@"[TRACE] Received POST request at 'http://localhost/fruitBowls/{existingBowl.StringId}/relationships/fruits' with body: <<{{ + ""data"": [ + {{ + ""type"": ""peaches"", + ""id"": ""{existingPeach.StringId}"" + }} + ] +}}>>", + $@"[TRACE] Entering PostRelationshipAsync(id: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""Color"": ""Red/Yellow"", + ""DiameterInCentimeters"": 0, + ""Id"": {existingPeach.Id}, + ""StringId"": ""{existingPeach.StringId}"" + }} +])", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftId: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""Color"": ""Red/Yellow"", + ""DiameterInCentimeters"": 0, + ""Id"": {existingPeach.Id}, + ""StringId"": ""{existingPeach.StringId}"" + }} +])", + $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingPeach.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingPeach.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {existingBowl.Id}, rightResourceIds: [ + {{ + ""Color"": ""Red/Yellow"", + ""DiameterInCentimeters"": 0, + ""Id"": {existingPeach.Id}, + ""StringId"": ""{existingPeach.StringId}"" + }} +])" + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs new file mode 100644 index 0000000000..68d251666c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class Peach : Fruit +{ + public override string Color => "Red/Yellow"; + + [Attr] + public double DiameterInCentimeters { get; set; } +} From 1e0d9c91584708f2af303ec28f93292c54d959be Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:41:41 +0100 Subject: [PATCH 32/53] Resharper: simplify dictionary lookups --- .../TranslationToSql/Builders/SelectStatementBuilder.cs | 2 +- src/JsonApiDotNetCore/Configuration/ResourceGraph.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs index 4e12b735c7..550faba632 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -436,7 +436,7 @@ private TableAccessorNode CreatePrimaryTableWithIdentityCondition(TableSourceNod private TableAccessorNode? FindRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) { Dictionary rightTableAccessors = _queryState.RelatedTables[leftTableAccessor]; - return rightTableAccessors.TryGetValue(relationship, out TableAccessorNode? rightTableAccessor) ? rightTableAccessor : null; + return rightTableAccessors.GetValueOrDefault(relationship); } private SelectNode ToSelect(bool isSubQuery, bool createAlias) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index edf67a3f8e..884b8ccd79 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -53,7 +53,7 @@ public ResourceType GetResourceType(string publicName) { ArgumentGuard.NotNull(publicName); - return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType? resourceType) ? resourceType : null; + return _resourceTypesByPublicName.GetValueOrDefault(publicName); } /// @@ -75,7 +75,7 @@ public ResourceType GetResourceType(Type resourceClrType) ArgumentGuard.NotNull(resourceClrType); Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; - return _resourceTypesByClrType.TryGetValue(typeToFind, out ResourceType? resourceType) ? resourceType : null; + return _resourceTypesByClrType.GetValueOrDefault(typeToFind); } private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) From 4d2ffd893b4c33d0a5503ae755941bbb5279326b Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:52:20 +0100 Subject: [PATCH 33/53] Workaround failure while installing latest PowerShell that requires .NET 8 --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 750c0476bc..08df1de510 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,8 @@ jobs: - name: Setup PowerShell (Ubuntu) if: matrix.os == 'ubuntu-latest' run: | - dotnet tool install --global PowerShell + # Temporary version downgrade because .NET 8 is not installed on runner. + dotnet tool install --global PowerShell --version 7.3.10 - name: Find latest PowerShell version (Windows) if: matrix.os == 'windows-latest' shell: pwsh From c1b911fbcd50633c42dc206c520bb9e9fb89d943 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Nov 2023 17:33:25 +0000 Subject: [PATCH 34/53] Update Microsoft.SourceLink.GitHub requirement from 1.1.* to 8.0.* (#1395) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 910b387f01..d780fbefbf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -36,7 +36,7 @@ 1.3.* 2023.3.* 7.0.* - 1.1.* + 8.0.* 7.0.* 17.8.* 2.5.* From fcda1b634548c8cf4df512ee2ef5ec90d1c3c88a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 29 Sep 2023 02:26:31 +0200 Subject: [PATCH 35/53] Multi-target against .NET 6 and .NET 8 --- .github/workflows/build.yml | 18 +++++--- Directory.Build.props | 34 ++------------- JsonApiDotNetCore.sln | 1 + README.md | 29 ++++++++----- benchmarks/Benchmarks.csproj | 4 +- cleanupcode.ps1 | 6 +-- docs/generate-examples.ps1 | 2 +- inspectcode.ps1 | 2 +- package-versions.props | 42 +++++++++++++++++++ .../DapperExample/DapperExample.csproj | 6 ++- .../Definitions/TodoItemDefinition.cs | 13 +++--- src/Examples/DapperExample/IClock.cs | 6 +++ src/Examples/DapperExample/Program.cs | 3 +- src/Examples/DapperExample/SystemClock.cs | 6 +++ .../DatabasePerTenantExample.csproj | 6 ++- .../GettingStarted/GettingStarted.csproj | 4 +- .../Definitions/TodoItemDefinition.cs | 18 ++++++-- .../JsonApiDotNetCoreExample.csproj | 6 ++- .../JsonApiDotNetCoreExample/Program.cs | 8 +++- .../MultiDbContextExample.csproj | 4 +- .../NoEntityFrameworkExample.csproj | 4 +- .../ReportsExample/ReportsExample.csproj | 9 ++-- .../JsonApiDotNetCore.Annotations.csproj | 5 ++- .../JsonApiDotNetCore.SourceGenerators.csproj | 3 +- .../JsonApiDotNetCore.csproj | 4 +- .../Response/FingerprintGenerator.cs | 4 ++ test/AnnotationTests/AnnotationTests.csproj | 5 ++- test/DapperTests/DapperTests.csproj | 4 +- .../IntegrationTests/DapperTestContext.cs | 7 +++- .../IntegrationTests/FrozenClock.cs | 11 +++++ test/DiscoveryTests/DiscoveryTests.csproj | 4 +- .../Creating/AtomicCreateResourceTests.cs | 1 - ...reateResourceWithClientGeneratedIdTests.cs | 1 - .../DateMustBeInThePastAttribute.cs | 2 +- .../Meta/AtomicResourceMetaTests.cs | 1 - .../AtomicModelStateValidationTests.cs | 1 - .../QueryStrings/AtomicQueryStringTests.cs | 1 - .../MusicTrackReleaseDefinition.cs | 2 +- .../Resources/AtomicUpdateResourceTests.cs | 1 - .../ApiControllerAttributeTests.cs | 6 +-- .../ModelState/ModelStateValidationTests.cs | 6 ++- .../FilterRewritingResourceDefinition.cs | 2 +- .../TimeOffset/FilterTimeOffsetRewriter.cs | 2 +- .../TimeOffset/TimeOffsetTests.cs | 1 - .../GiftCertificate.cs | 2 +- .../InjectionDbContext.cs | 1 - .../PostOffice.cs | 2 +- .../ResourceInjectionTests.cs | 1 - .../SoftDeletionAwareResourceService.cs | 2 +- .../SoftDeletion/SoftDeletionTests.cs | 1 - .../JsonApiDotNetCoreTests.csproj | 6 ++- .../MultiDbContextTests.csproj | 4 +- .../NoEntityFrameworkTests.csproj | 4 +- .../NullSafeExpressionRewriterTests.cs | 4 ++ .../SourceGeneratorTests.csproj | 4 +- .../TestBuildingBlocks/DbContextExtensions.cs | 3 ++ test/TestBuildingBlocks/FrozenSystemClock.cs | 1 - test/TestBuildingBlocks/ISystemClock.cs | 6 +++ .../TestBuildingBlocks.csproj | 6 ++- test/UnitTests/UnitTests.csproj | 4 +- 60 files changed, 233 insertions(+), 123 deletions(-) create mode 100644 package-versions.props create mode 100644 src/Examples/DapperExample/IClock.cs create mode 100644 src/Examples/DapperExample/SystemClock.cs create mode 100644 test/DapperTests/IntegrationTests/FrozenClock.cs create mode 100644 test/TestBuildingBlocks/ISystemClock.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08df1de510..e907bbbc02 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: | + 6.0.x + 8.0.x - name: Setup PowerShell (Ubuntu) if: matrix.os == 'ubuntu-latest' run: | @@ -182,7 +184,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: | + 6.0.x + 8.0.x - name: Git checkout uses: actions/checkout@v4 - name: Restore tools @@ -193,7 +197,7 @@ jobs: run: | $inspectCodeOutputPath = Join-Path $env:RUNNER_TEMP 'jetbrains-inspectcode-results.xml' Write-Output "INSPECT_CODE_OUTPUT_PATH=$inspectCodeOutputPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$inspectCodeOutputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:ContinuousIntegrationBuild=false --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal + dotnet jb inspectcode JsonApiDotNetCore.sln --build --dotnetcoresdk=$(dotnet --version) --output="$inspectCodeOutputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:ContinuousIntegrationBuild=false --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal - name: Verify outcome shell: pwsh run: | @@ -233,7 +237,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: | + 6.0.x + 8.0.x - name: Git checkout uses: actions/checkout@v4 with: @@ -254,13 +260,13 @@ jobs: $baseCommitHash = git rev-parse HEAD~1 Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash in pull request." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $headCommitHash -b $baseCommitHash --fail-on-diff --print-diff + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $headCommitHash -b $baseCommitHash --fail-on-diff --print-diff - name: CleanupCode (on branch) if: github.event_name == 'push' || github.event_name == 'release' shell: pwsh run: | Write-Output "Running code cleanup on all files." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version)--jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff publish: timeout-minutes: 60 diff --git a/Directory.Build.props b/Directory.Build.props index d780fbefbf..7ce9eb9a00 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,43 +13,15 @@ true - - - net6.0 - 4.1.0 - 0.4.1 - 6.0.0 - 2.14.1 - - - 6.0.* - 0.13.* - 34.0.* - 3.8.* - 4.7.* - 6.0.* - 2.1.* - 2.1.* - 7.0.* - 6.12.* - 2.3.* - 1.3.* - 2023.3.* - 7.0.* - 8.0.* - 7.0.* - 17.8.* - 2.5.* - - - - + + enable + latest enable false false diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 4f8bd6f8ef..e821d4175d 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -14,6 +14,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution CSharpGuidelinesAnalyzer.config = CSharpGuidelinesAnalyzer.config Directory.Build.props = Directory.Build.props tests.runsettings = tests.runsettings + package-versions.props = package-versions.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{026FBC6C-AF76-4568-9B87-EC73457899FD}" diff --git a/README.md b/README.md index fe2658711f..f34dfd1bee 100644 --- a/README.md +++ b/README.md @@ -76,17 +76,24 @@ app.MapControllers(); The following chart should help you pick the best version, based on your environment. See also our [versioning policy](./VERSIONING_POLICY.md). -| JsonApiDotNetCore | Status | .NET | Entity Framework Core | -| ----------------- | ----------- | -------- | --------------------- | -| 3.x | Stable | Core 2.x | 2.x | -| 4.x | Stable | Core 3.1 | 3.1 | -| | | Core 3.1 | 5 | -| | | 5 | 5 | -| | | 6 | 5 | -| 5.0.0-5.0.2 | Stable | 6 | 6 | -| 5.0.3+ | Stable | 6 | 6 | -| | | 6 | 7 | -| | | 7 | 7 | +| JsonApiDotNetCore | Status | .NET | Entity Framework Core | +| ----------------- | ------------ | -------- | --------------------- | +| 3.x | Stable | Core 2.x | 2.x | +| 4.x | Stable | Core 3.1 | 3.1, 5 | +| | | 5 | 5 | +| | | 6 | 5 | +| 5.0.0-5.0.2 | Stable | 6 | 6 | +| 5.0.3-5.4.0 | Stable | 6 | 6, 7 | +| | | 7 | 7 | +| 5.5+ | Stable | 6 | 6, 7 | +| | | 7 | 7 | +| | | 8 | 8 | +| master | Preview | 6 | 6, 7 | +| | | 7 | 7 | +| | | 8 | 8 | +| openapi | Experimental | 6 | 6, 7 | +| | | 7 | 7 | +| | | 8 | 8 | ## Contributing diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 1e97dd290f..9dbb9ba093 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -1,10 +1,12 @@ Exe - $(TargetFrameworkName) + net8.0 true + + diff --git a/cleanupcode.ps1 b/cleanupcode.ps1 index ba1b0ca4c0..3ab4d620ae 100644 --- a/cleanupcode.ps1 +++ b/cleanupcode.ps1 @@ -28,17 +28,17 @@ if ($revision) { if ($baseCommitHash -eq $headCommitHash) { Write-Output "Running code cleanup on staged/unstaged files." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified VerifySuccessExitCode } else { Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash, including staged/unstaged files." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified,commits -a $headCommitHash -b $baseCommitHash + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified,commits -a $headCommitHash -b $baseCommitHash VerifySuccessExitCode } } else { Write-Output "Running code cleanup on all files." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN VerifySuccessExitCode } diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1 index 4b13408460..ea6b2bd8f2 100644 --- a/docs/generate-examples.ps1 +++ b/docs/generate-examples.ps1 @@ -34,7 +34,7 @@ function Start-WebServer { Write-Output "Starting web server" $startTimeUtc = Get-Date -AsUTC $job = Start-Job -ScriptBlock { - dotnet run --project ..\src\Examples\GettingStarted\GettingStarted.csproj --configuration Debug --property:TreatWarningsAsErrors=True --urls=http://0.0.0.0:14141 + dotnet run --project ..\src\Examples\GettingStarted\GettingStarted.csproj --framework net8.0 --configuration Debug --property:TreatWarningsAsErrors=True --urls=http://0.0.0.0:14141 } $webProcessId = $null diff --git a/inspectcode.ps1 b/inspectcode.ps1 index b379bce1c6..14c3eb1736 100644 --- a/inspectcode.ps1 +++ b/inspectcode.ps1 @@ -10,7 +10,7 @@ if ($LastExitCode -ne 0) { $outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml') $resultPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.html') -dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal +dotnet jb inspectcode JsonApiDotNetCore.sln --dotnetcoresdk=$(dotnet --version) --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal if ($LastExitCode -ne 0) { throw "Code inspection failed with exit code $LastExitCode" diff --git a/package-versions.props b/package-versions.props new file mode 100644 index 0000000000..dd842f59f6 --- /dev/null +++ b/package-versions.props @@ -0,0 +1,42 @@ + + + + 4.1.0 + 0.4.1 + 2.14.1 + + + 0.13.* + 34.0.* + 4.7.* + 6.0.* + 2.1.* + 6.12.* + 2.3.* + 1.3.* + 8.0.* + 17.8.* + 2.5.* + + + + + 8.0.0 + + + 8.0.* + 8.0.*-* + $(AspNetCoreVersion) + + + + + 6.0.0 + + + 6.0.* + 2.1.* + 7.0.* + 7.0.* + + diff --git a/src/Examples/DapperExample/DapperExample.csproj b/src/Examples/DapperExample/DapperExample.csproj index 4445af8c1e..f49c3e4b40 100644 --- a/src/Examples/DapperExample/DapperExample.csproj +++ b/src/Examples/DapperExample/DapperExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + diff --git a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs index 77bf6f9548..6fed60fadd 100644 --- a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs @@ -6,21 +6,20 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Authentication; namespace DapperExample.Definitions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemDefinition : JsonApiResourceDefinition { - private readonly ISystemClock _systemClock; + private readonly IClock _clock; - public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + public TodoItemDefinition(IResourceGraph resourceGraph, IClock clock) : base(resourceGraph) { - ArgumentGuard.NotNull(systemClock); + ArgumentGuard.NotNull(clock); - _systemClock = systemClock; + _clock = clock; } public override SortExpression OnApplySort(SortExpression? existingSort) @@ -41,11 +40,11 @@ public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeO { if (writeOperation == WriteOperationKind.CreateResource) { - resource.CreatedAt = _systemClock.UtcNow; + resource.CreatedAt = _clock.UtcNow; } else if (writeOperation == WriteOperationKind.UpdateResource) { - resource.LastModifiedAt = _systemClock.UtcNow; + resource.LastModifiedAt = _clock.UtcNow; } return Task.CompletedTask; diff --git a/src/Examples/DapperExample/IClock.cs b/src/Examples/DapperExample/IClock.cs new file mode 100644 index 0000000000..0319c42480 --- /dev/null +++ b/src/Examples/DapperExample/IClock.cs @@ -0,0 +1,6 @@ +namespace DapperExample; + +public interface IClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/src/Examples/DapperExample/Program.cs b/src/Examples/DapperExample/Program.cs index e19e45478f..00ab54ca97 100644 --- a/src/Examples/DapperExample/Program.cs +++ b/src/Examples/DapperExample/Program.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Repositories; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,7 +17,7 @@ // Add services to the container. -builder.Services.TryAddSingleton(); +builder.Services.TryAddSingleton(); DatabaseProvider databaseProvider = GetDatabaseProvider(builder.Configuration); string? connectionString = builder.Configuration.GetConnectionString($"DapperExample{databaseProvider}"); diff --git a/src/Examples/DapperExample/SystemClock.cs b/src/Examples/DapperExample/SystemClock.cs new file mode 100644 index 0000000000..3fed87c586 --- /dev/null +++ b/src/Examples/DapperExample/SystemClock.cs @@ -0,0 +1,6 @@ +namespace DapperExample; + +public sealed class SystemClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index c219dfd0f5..0ccb4bbc5f 100644 --- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index 9e0c80b7f4..1f4645f323 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + { - private readonly ISystemClock _systemClock; + private readonly Func _getUtcNow; +#if NET6_0 public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) : base(resourceGraph) { - _systemClock = systemClock; + _getUtcNow = () => systemClock.UtcNow; } +#else + public TodoItemDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) + : base(resourceGraph) + { + _getUtcNow = timeProvider.GetUtcNow; + } +#endif public override SortExpression OnApplySort(SortExpression? existingSort) { @@ -38,11 +48,11 @@ public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeO { if (writeOperation == WriteOperationKind.CreateResource) { - resource.CreatedAt = _systemClock.UtcNow; + resource.CreatedAt = _getUtcNow(); } else if (writeOperation == WriteOperationKind.UpdateResource) { - resource.LastModifiedAt = _systemClock.UtcNow; + resource.LastModifiedAt = _getUtcNow(); } return Task.CompletedTask; diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index c219dfd0f5..0ccb4bbc5f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index a65563c681..52b27759e9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -4,10 +4,12 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; +#if NET6_0 +using Microsoft.AspNetCore.Authentication; +#endif [assembly: ExcludeFromCodeCoverage] @@ -45,7 +47,11 @@ static void ConfigureServices(WebApplicationBuilder builder) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure services"); +#if NET6_0 builder.Services.TryAddSingleton(); +#else + builder.Services.TryAddSingleton(TimeProvider.System); +#endif builder.Services.AddDbContext(options => { diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index 9e0c80b7f4..1f4645f323 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - $(TargetFrameworkName) + net8.0;net6.0 + + - $(TargetFrameworkName) + net8.0;net6.0 + + - - - - - diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index 48ca676b4a..1b93c24975 100644 --- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -1,12 +1,13 @@ - $(TargetFrameworkName);netstandard1.0 + net8.0;net6.0;netstandard1.0 true true JsonApiDotNetCore - latest + + $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index ca6de1a5b7..f784ada6f9 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -5,10 +5,11 @@ true false $(NoWarn);NU5128 - latest true + + $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 4407edaf1f..1757b54a82 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,10 +1,12 @@ - $(TargetFrameworkName) + net8.0;net6.0 true true + + $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api diff --git a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs index e0b3e56b10..3ecc0b2c5a 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs @@ -6,7 +6,11 @@ namespace JsonApiDotNetCore.Serialization.Response; /// internal sealed class FingerprintGenerator : IFingerprintGenerator { +#if NET6_0 private static readonly byte[] Separator = Encoding.UTF8.GetBytes("|"); +#else + private static readonly byte[] Separator = "|"u8.ToArray(); +#endif private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray(); private static uint ToLookupEntry(int index) diff --git a/test/AnnotationTests/AnnotationTests.csproj b/test/AnnotationTests/AnnotationTests.csproj index b712b1bb67..081046adb0 100644 --- a/test/AnnotationTests/AnnotationTests.csproj +++ b/test/AnnotationTests/AnnotationTests.csproj @@ -1,9 +1,10 @@ - $(TargetFrameworkName);netstandard2.0 - latest + net8.0;net6.0;netstandard2.0 + + diff --git a/test/DapperTests/DapperTests.csproj b/test/DapperTests/DapperTests.csproj index c7ce96a37a..45d9c6a88d 100644 --- a/test/DapperTests/DapperTests.csproj +++ b/test/DapperTests/DapperTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs index 084444e896..0257490065 100644 --- a/test/DapperTests/IntegrationTests/DapperTestContext.cs +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -8,7 +8,6 @@ using FluentAssertions.Extensions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; @@ -17,6 +16,7 @@ using Microsoft.Extensions.Logging; using TestBuildingBlocks; using Xunit.Abstractions; +using IClock = DapperExample.IClock; namespace DapperTests.IntegrationTests; @@ -74,7 +74,7 @@ private WebApplicationFactory CreateFactory() builder.ConfigureServices(services => { - services.AddSingleton(new FrozenSystemClock + services.AddSingleton(new FrozenClock { UtcNow = FrozenTime }); @@ -114,7 +114,10 @@ public async Task ClearAllTablesAsync(DbContext dbContext) _ => throw new NotSupportedException($"Unsupported database provider '{databaseProvider}'.") }; +#pragma warning disable EF1002 // Risk of vulnerability to SQL injection. + // Justification: Table names cannot be parameterized. await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM {escapedTableName}"); +#pragma warning restore EF1002 // Risk of vulnerability to SQL injection. } } } diff --git a/test/DapperTests/IntegrationTests/FrozenClock.cs b/test/DapperTests/IntegrationTests/FrozenClock.cs new file mode 100644 index 0000000000..0de2390b71 --- /dev/null +++ b/test/DapperTests/IntegrationTests/FrozenClock.cs @@ -0,0 +1,11 @@ +using DapperExample; +using FluentAssertions.Extensions; + +namespace DapperTests.IntegrationTests; + +internal sealed class FrozenClock : IClock +{ + private static readonly DateTimeOffset DefaultTime = 1.January(2020).At(1, 1, 1).AsUtc(); + + public DateTimeOffset UtcNow { get; set; } = DefaultTime; +} diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj index a09e322203..a64d3be689 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 99b3b3bc5b..5639d44f40 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -3,7 +3,6 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 5bd4acb39e..62013322e5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -2,7 +2,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs index 0421f2e396..b273eca898 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 3edb88b14a..f98bc1b49d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -4,7 +4,6 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 4a8b7d9e52..98564f3a57 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -1,7 +1,6 @@ using System.Net; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index a9d0fbd44b..6db0926773 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -2,7 +2,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs index 65ab4a4344..84827322ad 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -2,8 +2,8 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Primitives; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 0af67d4c20..943d2d9bb4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -3,7 +3,6 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index 28e6ba2439..a27ef77329 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -33,7 +33,7 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with ErrorObject error = responseDocument.Errors[0]; error.Links.ShouldNotBeNull(); - error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); + error.Links.About.Should().StartWith("https://tools.ietf.org/html/rfc"); } [Fact] @@ -66,7 +66,7 @@ public async Task ProblemDetails_from_invalid_ModelState_is_translated_into_erro ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.BadRequest); error1.Links.ShouldNotBeNull(); - error1.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1"); + error1.Links.About.Should().StartWith("https://tools.ietf.org/html/rfc"); error1.Title.Should().Be("One or more validation errors occurred."); error1.Detail.Should().Be("The Name field is required."); error1.Source.Should().BeNull(); @@ -74,7 +74,7 @@ public async Task ProblemDetails_from_invalid_ModelState_is_translated_into_erro ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.BadRequest); error2.Links.ShouldNotBeNull(); - error2.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1"); + error2.Links.About.Should().StartWith("https://tools.ietf.org/html/rfc"); error2.Title.Should().Be("One or more validation errors occurred."); error2.Detail.Should().Be("The field YearOfBirth must be between 1900 and 2050."); error2.Source.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 55e293aab1..e6e83fa7c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -1,9 +1,11 @@ using System.Net; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; +#if NET6_0 +using Microsoft.Extensions.DependencyInjection; +#endif namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; @@ -19,11 +21,13 @@ public ModelStateValidationTests(IntegrationTestContext(); testContext.UseController(); +#if NET6_0 testContext.ConfigureServices(services => { // Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation. services.AddDateOnlyTimeOnlyStringConverters(); }); +#endif } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs index 138ccdeafd..c3e5941a19 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Authentication; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs index 8adc07fdf0..2d2ee4da89 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs @@ -1,5 +1,5 @@ using JsonApiDotNetCore.Queries.Expressions; -using Microsoft.AspNetCore.Authentication; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs index baec74602b..8b8062674c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs index 2cd1ff91d8..6da63a30cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs @@ -2,7 +2,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.AspNetCore.Authentication; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceConstructorInjection; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs index bb3bbfd85b..57ec534cae 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs @@ -1,6 +1,5 @@ using JetBrains.Annotations; using JsonApiDotNetCore; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs index da163d9cee..f7bd504cd8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs @@ -2,7 +2,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.AspNetCore.Authentication; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceConstructorInjection; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 403f86757b..7dd8c92726 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -2,7 +2,6 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 299a40bad8..2b6e3be294 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -5,8 +5,8 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index f750826f8c..7bd7fe66f1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -3,7 +3,6 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index 7a3fa8ddf8..38d665aa5b 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index 394d72e2d2..54497bfada 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index 84a36dcbb1..080666d491 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + diff --git a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs index 57da032819..e8fe1585ca 100644 --- a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs +++ b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs @@ -498,7 +498,11 @@ public void Can_rewrite_order_by_clause_with_IntPtr() Parent = new TestResource { Id = generator.GetNext(), +#if NET6_0 Pointer = (IntPtr)1 +#else + Pointer = 1 +#endif } } }; diff --git a/test/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index d361de38e5..e28bdc20d1 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + diff --git a/test/TestBuildingBlocks/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs index 7f32073874..d3a8a0e8ad 100644 --- a/test/TestBuildingBlocks/DbContextExtensions.cs +++ b/test/TestBuildingBlocks/DbContextExtensions.cs @@ -44,7 +44,10 @@ private static async Task ClearTablesAsync(this DbContext dbContext, params Type } else { +#pragma warning disable EF1002 // Risk of vulnerability to SQL injection. + // Justification: Table names cannot be parameterized. await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM \"{tableName}\""); +#pragma warning restore EF1002 // Risk of vulnerability to SQL injection. } } } diff --git a/test/TestBuildingBlocks/FrozenSystemClock.cs b/test/TestBuildingBlocks/FrozenSystemClock.cs index a1d85e1fcc..6ffe8feaaf 100644 --- a/test/TestBuildingBlocks/FrozenSystemClock.cs +++ b/test/TestBuildingBlocks/FrozenSystemClock.cs @@ -1,5 +1,4 @@ using FluentAssertions.Extensions; -using Microsoft.AspNetCore.Authentication; namespace TestBuildingBlocks; diff --git a/test/TestBuildingBlocks/ISystemClock.cs b/test/TestBuildingBlocks/ISystemClock.cs new file mode 100644 index 0000000000..eb1c8628a3 --- /dev/null +++ b/test/TestBuildingBlocks/ISystemClock.cs @@ -0,0 +1,6 @@ +namespace TestBuildingBlocks; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index ba9a2f5da3..ae6cf32ff6 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + @@ -15,7 +17,7 @@ - + diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index f8dcbce984..99fc7ce781 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + From d1a36649743a0a420a1e806079a61e5339510796 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 17 Nov 2023 15:14:56 +0100 Subject: [PATCH 36/53] Create codeql.yml --- .github/workflows/codeql.yml | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..5b1868eae5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL" + +on: + push: + branches: [ 'master', 'release/**' ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ 'master', 'release/**' ] + schedule: + - cron: '0 0 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: 'ubuntu-latest' + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [ 'csharp' ] + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 8.0.x + - name: Git checkout + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From f3f382a9ac74481dab2e11e11e38c88dc11ea6a7 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 17 Nov 2023 15:50:10 +0100 Subject: [PATCH 37/53] Revert "Workaround failure while installing latest PowerShell that requires .NET 8" This reverts commit 4d2ffd893b4c33d0a5503ae755941bbb5279326b. --- .github/workflows/build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e907bbbc02..ca7042c976 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,8 +53,7 @@ jobs: - name: Setup PowerShell (Ubuntu) if: matrix.os == 'ubuntu-latest' run: | - # Temporary version downgrade because .NET 8 is not installed on runner. - dotnet tool install --global PowerShell --version 7.3.10 + dotnet tool install --global PowerShell - name: Find latest PowerShell version (Windows) if: matrix.os == 'windows-latest' shell: pwsh From 875ac5484acf49cde842e75d43a482a801c22a53 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 18 Nov 2023 19:05:43 +0100 Subject: [PATCH 38/53] Resharper: Use collection expression --- JsonApiDotNetCore.sln.DotSettings | 3 + WarningSeverities.DotSettings | 2 + .../DapperExample/Data/RotatingList.cs | 2 +- .../Definitions/TodoItemDefinition.cs | 5 +- .../Repositories/DapperFacade.cs | 4 +- .../Repositories/ResourceChangeDetector.cs | 22 +++--- .../Repositories/ResultSetMapper.cs | 12 +-- .../Repositories/SqlCaptureStore.cs | 2 +- .../Builders/InsertStatementBuilder.cs | 2 +- .../Builders/SelectStatementBuilder.cs | 36 ++++----- .../Builders/SqlQueryBuilder.cs | 10 +-- .../UpdateResourceStatementBuilder.cs | 2 +- .../DataModel/BaseDataModelService.cs | 4 +- .../DataModel/FromEntitiesDataModelService.cs | 4 +- .../TranslationToSql/ParameterFormatter.cs | 6 +- .../ColumnSelectorUsageCollector.cs | 2 +- .../StaleColumnReferenceRewriter.cs | 4 +- .../UnusedSelectorsRewriter.cs | 4 +- .../TranslationToSql/TreeNodes/SelectNode.cs | 2 +- .../TranslationToSql/TreeNodes/TableNode.cs | 2 +- .../Controllers/NonJsonApiController.cs | 5 +- .../Data/RotatingList.cs | 2 +- .../Definitions/TodoItemDefinition.cs | 5 +- .../NoEntityFrameworkExample/Data/Database.cs | 26 +++---- .../NullSafeExpressionRewriter.cs | 4 +- .../Configuration/ResourceType.cs | 8 +- .../ObjectExtensions.cs | 15 +--- .../ResourceDescriptorAssemblyCache.cs | 2 +- .../Configuration/ResourceGraph.cs | 4 +- .../Configuration/ResourceGraphBuilder.cs | 8 +- .../Configuration/ServiceDiscoveryFacade.cs | 17 ++-- .../Diagnostics/CascadingCodeTimer.cs | 2 +- .../Errors/InvalidModelStateException.cs | 2 +- .../Middleware/JsonApiRoutingConvention.cs | 6 +- .../Expressions/IncludeChainConverter.cs | 2 +- .../Queries/Parsing/FilterParser.cs | 6 +- .../Queries/Parsing/IncludeParser.cs | 4 +- .../QueryableBuilding/IncludeClauseBuilder.cs | 2 +- .../QueryableBuilding/LambdaScopeFactory.cs | 2 +- .../QueryStrings/FieldChains/MatchState.cs | 2 +- .../FieldChains/MatchTraceScope.cs | 2 +- .../FieldChains/PatternMatcher.cs | 2 +- .../QueryStrings/FieldChains/PatternParser.cs | 6 +- .../FilterQueryStringParameterReader.cs | 2 +- .../PaginationQueryStringParameterReader.cs | 2 +- ...ourceDefinitionQueryableParameterReader.cs | 2 +- .../SortQueryStringParameterReader.cs | 2 +- .../Resources/TargetedFields.cs | 4 +- .../Serialization/Response/MetaBuilder.cs | 2 +- .../Response/ResourceObjectTreeNode.cs | 8 +- .../UnitTests/SqlTreeNodeVisitorTests.cs | 4 +- .../ServiceDiscoveryFacadeTests.cs | 2 +- .../AtomicAddToToManyRelationshipTests.cs | 4 +- ...AtomicRemoveFromToManyRelationshipTests.cs | 4 +- .../AtomicReplaceToManyRelationshipTests.cs | 4 +- .../AtomicReplaceToManyRelationshipTests.cs | 4 +- .../Authorization/Scopes/AuthScopeSet.cs | 2 +- .../IdObfuscation/HexadecimalCodec.cs | 2 +- .../Microservices/MessagingGroupDefinition.cs | 2 +- .../Microservices/MessagingUserDefinition.cs | 2 +- .../NonJsonApiController.cs | 6 +- .../CustomFunctions/Sum/SumFilterParser.cs | 6 +- .../SparseFieldSets/ResourceCaptureStore.cs | 2 +- .../Reading/StarDefinition.cs | 5 +- .../WheelSortDefinition.cs | 5 +- .../Serialization/SerializationFakers.cs | 4 +- .../Serialization/SerializationTests.cs | 2 +- .../TestableQueryExpressionRewriter.cs | 2 +- .../CreateSortExpressionFromLambdaTests.cs | 78 ++++++------------- .../CompilationBuilder.cs | 4 +- .../SourceGeneratorTests/SourceCodeBuilder.cs | 2 +- test/TestBuildingBlocks/FakeLoggerFactory.cs | 2 +- .../IgnoreLineEndingsComparer.cs | 4 +- 73 files changed, 193 insertions(+), 245 deletions(-) diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index bf7a5182f0..64125e085d 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -93,6 +93,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); SUGGESTION SUGGESTION WARNING + SUGGESTION <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSReformatInactiveBranches>True</CSReformatInactiveBranches></Profile> JADNC Full Cleanup Required @@ -125,6 +126,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); False False True + 1 NEVER NEVER False @@ -145,6 +147,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); True WRAP_IF_LONG 160 + CHOP_IF_LONG WRAP_IF_LONG CHOP_ALWAYS CHOP_ALWAYS diff --git a/WarningSeverities.DotSettings b/WarningSeverities.DotSettings index 96f358da23..b5e5ca9c42 100644 --- a/WarningSeverities.DotSettings +++ b/WarningSeverities.DotSettings @@ -240,6 +240,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -264,4 +265,5 @@ WARNING WARNING WARNING + WARNING \ No newline at end of file diff --git a/src/Examples/DapperExample/Data/RotatingList.cs b/src/Examples/DapperExample/Data/RotatingList.cs index 3fa04762a3..67c19bea4a 100644 --- a/src/Examples/DapperExample/Data/RotatingList.cs +++ b/src/Examples/DapperExample/Data/RotatingList.cs @@ -4,7 +4,7 @@ internal abstract class RotatingList { public static RotatingList Create(int count, Func createElement) { - List elements = new(); + List elements = []; for (int index = 0; index < count; index++) { diff --git a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs index 6fed60fadd..dc7c1802c8 100644 --- a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs @@ -29,11 +29,10 @@ public override SortExpression OnApplySort(SortExpression? existingSort) private SortExpression GetDefaultSortOrder() { - return CreateSortExpressionFromLambda(new PropertySortOrder - { + return CreateSortExpressionFromLambda([ (todoItem => todoItem.Priority, ListSortDirection.Ascending), (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) - }); + ]); } public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) diff --git a/src/Examples/DapperExample/Repositories/DapperFacade.cs b/src/Examples/DapperExample/Repositories/DapperFacade.cs index d3247967f9..4d30e430c7 100644 --- a/src/Examples/DapperExample/Repositories/DapperFacade.cs +++ b/src/Examples/DapperExample/Repositories/DapperFacade.cs @@ -38,7 +38,7 @@ public IReadOnlyCollection BuildSqlCommandsForOneToOneRelatio { ArgumentGuard.NotNull(changeDetector); - List sqlCommands = new(); + List sqlCommands = []; foreach ((HasOneAttribute relationship, (object? currentRightId, object newRightId)) in changeDetector.GetOneToOneRelationshipsChangedToNotNull()) { @@ -81,7 +81,7 @@ public IReadOnlyCollection BuildSqlCommandsForChangedRelation { ArgumentGuard.NotNull(changeDetector); - List sqlCommands = new(); + List sqlCommands = []; foreach ((HasOneAttribute hasOneRelationship, (object? currentRightId, object? newRightId)) in changeDetector .GetChangedToOneRelationshipsWithForeignKeyAtRightSide()) diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs index bd6537b547..1d9b998340 100644 --- a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -15,11 +15,11 @@ internal sealed class ResourceChangeDetector private readonly CollectionConverter _collectionConverter = new(); private readonly IDataModelService _dataModelService; - private Dictionary _currentColumnValues = new(); - private Dictionary _newColumnValues = new(); + private Dictionary _currentColumnValues = []; + private Dictionary _newColumnValues = []; - private Dictionary> _currentRightResourcesByRelationship = new(); - private Dictionary> _newRightResourcesByRelationship = new(); + private Dictionary> _currentRightResourcesByRelationship = []; + private Dictionary> _newRightResourcesByRelationship = []; public ResourceType ResourceType { get; } @@ -52,7 +52,7 @@ public void CaptureNewValues(IIdentifiable resource) private Dictionary CaptureColumnValues(IIdentifiable resource) { - Dictionary columnValues = new(); + Dictionary columnValues = []; foreach ((string columnName, ResourceFieldAttribute? _) in _dataModelService.GetColumnMappings(ResourceType)) { @@ -64,7 +64,7 @@ public void CaptureNewValues(IIdentifiable resource) private Dictionary> CaptureRightResourcesByRelationship(IIdentifiable resource) { - Dictionary> relationshipValues = new(); + Dictionary> relationshipValues = []; foreach (RelationshipAttribute relationship in ResourceType.Relationships) { @@ -107,7 +107,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName public IReadOnlyDictionary GetOneToOneRelationshipsChangedToNotNull() { - Dictionary changes = new(); + Dictionary changes = []; foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) { @@ -135,7 +135,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName public IReadOnlyDictionary GetChangedColumnValues() { - Dictionary changes = new(); + Dictionary changes = []; foreach ((string columnName, object? newColumnValue) in _newColumnValues) { @@ -152,7 +152,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName public IReadOnlyDictionary GetChangedToOneRelationshipsWithForeignKeyAtRightSide() { - Dictionary changes = new(); + Dictionary changes = []; foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) { @@ -183,7 +183,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName public IReadOnlyDictionary currentRightIds, ISet newRightIds)> GetChangedToManyRelationships() { - Dictionary currentRightIds, ISet newRightIds)> changes = new(); + Dictionary currentRightIds, ISet newRightIds)> changes = []; foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) { @@ -194,7 +194,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName HashSet currentRightIds = _currentRightResourcesByRelationship.TryGetValue(hasManyRelationship, out ISet? currentRightResources) ? currentRightResources.Select(resource => resource.GetTypedId()).ToHashSet() - : new HashSet(); + : []; if (!currentRightIds.SetEquals(newRightIds)) { diff --git a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs index e0a6efddd0..61421d7331 100644 --- a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs +++ b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs @@ -12,20 +12,20 @@ namespace DapperExample.Repositories; internal sealed class ResultSetMapper where TResource : class, IIdentifiable { - private readonly List _joinObjectTypes = new(); + private readonly List _joinObjectTypes = []; // For each object type, we keep a map of ID/instance pairs. // Note we don't do full bidirectional relationship fix-up; this just avoids duplicate instances. - private readonly Dictionary> _resourceByTypeCache = new(); + private readonly Dictionary> _resourceByTypeCache = []; // Optimization to avoid unneeded calls to expensive Activator.CreateInstance() method, which is needed multiple times per row. - private readonly Dictionary _defaultValueByTypeCache = new(); + private readonly Dictionary _defaultValueByTypeCache = []; // Used to determine where in the tree of included relationships a join object belongs to. private readonly Dictionary _includeElementToJoinObjectArrayIndexLookup = new(ReferenceEqualityComparer.Instance); // The return value of the mapping process. - private readonly List _primaryResourcesInOrder = new(); + private readonly List _primaryResourcesInOrder = []; // The included relationships for which an INNER/LEFT JOIN statement was produced, which we're mapping. private readonly IncludeExpression _include; @@ -36,7 +36,7 @@ public ResultSetMapper(IncludeExpression? include) { _include = include ?? IncludeExpression.Empty; _joinObjectTypes.Add(typeof(TResource)); - _resourceByTypeCache[typeof(TResource)] = new Dictionary(); + _resourceByTypeCache[typeof(TResource)] = []; var walker = new IncludeElementWalker(_include); int index = 1; @@ -44,7 +44,7 @@ public ResultSetMapper(IncludeExpression? include) foreach (IncludeElementExpression includeElement in walker.BreadthFirstEnumerate()) { _joinObjectTypes.Add(includeElement.Relationship.RightType.ClrType); - _resourceByTypeCache[includeElement.Relationship.RightType.ClrType] = new Dictionary(); + _resourceByTypeCache[includeElement.Relationship.RightType.ClrType] = []; _includeElementToJoinObjectArrayIndexLookup[includeElement] = index; index++; diff --git a/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs index 272dedbd37..15d4e95d81 100644 --- a/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs +++ b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs @@ -9,7 +9,7 @@ namespace DapperExample.Repositories; [PublicAPI] public sealed class SqlCaptureStore { - private readonly List _sqlCommands = new(); + private readonly List _sqlCommands = []; public IReadOnlyList SqlCommands => _sqlCommands; diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs index b362a0e7b4..7e444b45a1 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs @@ -28,7 +28,7 @@ public InsertNode Build(ResourceType resourceType, IReadOnlyDictionary GetColumnAssignments(IReadOnlyDictionary columnsToSet, TableNode table) { - List assignments = new(); + List assignments = []; ColumnNode idColumn = table.GetIdColumn(table.Alias); foreach ((string columnName, object? columnValue) in columnsToSet) diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs index 550faba632..cd97a63d32 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -23,16 +23,16 @@ internal sealed class SelectStatementBuilder : QueryExpressionVisitor> _selectorsPerTable; + private readonly Dictionary> _selectorsPerTable = []; // Used to assign unique names when adding selectors, in case tables are joined that would result in duplicate column names. - private readonly HashSet _selectorNamesUsed; + private readonly HashSet _selectorNamesUsed = []; // Filter constraints. - private readonly List _whereFilters; + private readonly List _whereFilters = []; // Sorting on columns, or COUNT(*) in a sub-query. - private readonly List _orderByTerms; + private readonly List _orderByTerms = []; // Indicates whether to select a set of columns, the number of rows, or only the first (unnamed) column. private SelectShape _selectShape; @@ -45,10 +45,6 @@ public SelectStatementBuilder(IDataModelService dataModelService, ILoggerFactory private SelectStatementBuilder(QueryState queryState) { _queryState = queryState; - _selectorsPerTable = new Dictionary>(); - _selectorNamesUsed = new HashSet(); - _whereFilters = new List(); - _orderByTerms = new List(); } public SelectNode Build(QueryLayer queryLayer, SelectShape selectShape) @@ -105,7 +101,7 @@ private void TrackPrimaryTable(TableAccessorNode tableAccessor) throw new InvalidOperationException("A primary table already exists."); } - _queryState.RelatedTables.Add(tableAccessor, new Dictionary()); + _queryState.RelatedTables.Add(tableAccessor, []); _selectorsPerTable[tableAccessor] = _selectShape switch { @@ -146,8 +142,8 @@ private void ConvertQueryLayer(QueryLayer queryLayer, TableAccessorNode tableAcc private void ConvertFieldSelectors(FieldSelectors selectors, TableAccessorNode tableAccessor) { - HashSet selectedColumns = new(); - Dictionary nextLayers = new(); + HashSet selectedColumns = []; + Dictionary nextLayers = []; if (selectors.IsEmpty || selectors.ContainsReadOnlyAttribute || selectors.ContainsOnlyRelationships) { @@ -204,7 +200,7 @@ private void SetColumnSelectors(TableAccessorNode tableAccessor, IEnumerable PreserveColumnOrderEnsuringUniqueNames(IEnumerable columns) { - List selectors = new(); + List selectors = []; foreach (ColumnNode column in columns) { @@ -219,12 +215,12 @@ private List PreserveColumnOrderEnsuringUniqueNames(IEnumerable OrderColumnsWithIdAtFrontEnsuringUniqueNames(IEnumerable columns) { - Dictionary> selectorsPerTable = new(); + Dictionary> selectorsPerTable = []; foreach (ColumnNode column in columns.OrderBy(column => column.GetTableAliasIndex()).ThenBy(column => column.Name)) { string tableAlias = column.TableAlias ?? "!"; - selectorsPerTable.TryAdd(tableAlias, new List()); + selectorsPerTable.TryAdd(tableAlias, []); string uniqueName = GetUniqueSelectorName(column.Name); string? selectorAlias = uniqueName != column.Name ? uniqueName : null; @@ -354,7 +350,7 @@ private ComparisonNode CreateJoinCondition(TableSourceNode outerTableSource, Rel private void TrackRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableAccessorNode rightTableAccessor) { - _queryState.RelatedTables.Add(rightTableAccessor, new Dictionary()); + _queryState.RelatedTables.Add(rightTableAccessor, []); _selectorsPerTable[rightTableAccessor] = Array.Empty(); _queryState.RelatedTables[leftTableAccessor].Add(relationship, rightTableAccessor); @@ -362,7 +358,7 @@ private void TrackRelatedTable(TableAccessorNode leftTableAccessor, Relationship private IReadOnlyList MapSelectorsFromSubQuery(IEnumerable innerSelectorsToKeep, SelectNode select) { - List outerColumnsToKeep = new(); + List outerColumnsToKeep = []; foreach (SelectorNode innerSelector in innerSelectorsToKeep) { @@ -385,7 +381,7 @@ private IReadOnlyList MapSelectorsFromSubQuery(IEnumerable MapOrderingsFromSubQuery(IEnumerable innerOrderingsToKeep, SelectNode select) { - List orderingsToKeep = new(); + List orderingsToKeep = []; foreach (OrderByTermNode innerTerm in innerOrderingsToKeep) { @@ -470,7 +466,7 @@ private SelectNode ToSelect(bool isSubQuery, bool createAlias) private static Dictionary> AliasSelectorsToTableColumnNames( Dictionary> selectorsPerTable) { - Dictionary> aliasedSelectors = new(); + Dictionary> aliasedSelectors = []; foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectorsPerTable) { @@ -754,11 +750,11 @@ private sealed class QueryState // Prevents importing a table multiple times and enables to reference a table imported by an inner/outer query. // In case of sub-queries, this may include temporary tables that won't survive in the final query. - public Dictionary> RelatedTables { get; } = new(); + public Dictionary> RelatedTables { get; } = []; // In case of sub-queries, we track old/new table aliases, so we can rewrite stale references afterwards. // This cannot be done in the moment itself, because references to tables are on method call stacks. - public Dictionary OldToNewTableAliasMap { get; } = new(); + public Dictionary OldToNewTableAliasMap { get; } = []; public QueryState(IDataModelService dataModelService, TableAliasGenerator tableAliasGenerator, ParameterGenerator parameterGenerator, ILoggerFactory loggerFactory) diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs index f8a3412b36..3e3d48eb10 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs @@ -11,23 +11,23 @@ namespace DapperExample.TranslationToSql.Builders; internal sealed class SqlQueryBuilder : SqlTreeNodeVisitor { private static readonly char[] SpecialCharactersInLikeDefault = - { + [ '\\', '%', '_' - }; + ]; private static readonly char[] SpecialCharactersInLikeSqlServer = - { + [ '\\', '%', '_', '[', ']' - }; + ]; private readonly DatabaseProvider _databaseProvider; - private readonly Dictionary _parametersByName = new(); + private readonly Dictionary _parametersByName = []; private int _indentDepth; private char[] SpecialCharactersInLike => diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs index ad4514dca6..62fc3b7e20 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs @@ -32,7 +32,7 @@ public UpdateNode Build(ResourceType resourceType, IReadOnlyDictionary GetColumnAssignments(IReadOnlyDictionary columnsToUpdate, TableNode table) { - List assignments = new(); + List assignments = []; foreach ((string columnName, object? columnValue) in columnsToUpdate) { diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs index c964b6b3e7..589852ad80 100644 --- a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs @@ -16,7 +16,7 @@ namespace DapperExample.TranslationToSql.DataModel; /// public abstract class BaseDataModelService : IDataModelService { - private readonly Dictionary> _columnMappingsByType = new(); + private readonly Dictionary> _columnMappingsByType = []; protected IResourceGraph ResourceGraph { get; } @@ -54,7 +54,7 @@ private void ScanColumnMappings() private IReadOnlyDictionary ScanColumnMappings(ResourceType resourceType) { - Dictionary mappings = new(); + Dictionary mappings = []; foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) { diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs index 81f2778a14..0f030debdb 100644 --- a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs @@ -14,8 +14,8 @@ namespace DapperExample.TranslationToSql.DataModel; /// public sealed class FromEntitiesDataModelService : BaseDataModelService { - private readonly Dictionary _foreignKeysByRelationship = new(); - private readonly Dictionary _columnNullabilityPerAttribute = new(); + private readonly Dictionary _foreignKeysByRelationship = []; + private readonly Dictionary _columnNullabilityPerAttribute = []; private string? _connectionString; private DatabaseProvider? _databaseProvider; diff --git a/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs index 9d4053b5a4..5dc1591bca 100644 --- a/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs +++ b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs @@ -8,8 +8,8 @@ namespace DapperExample.TranslationToSql; /// internal sealed class ParameterFormatter { - private static readonly HashSet NumericTypes = new[] - { + private static readonly HashSet NumericTypes = + [ typeof(bool), typeof(int), typeof(uint), @@ -21,7 +21,7 @@ internal sealed class ParameterFormatter typeof(float), typeof(double), typeof(decimal) - }.ToHashSet(); + ]; public string Format(string parameterName, object? parameterValue) { diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs index ac447bcdc3..b5d560448c 100644 --- a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs @@ -8,7 +8,7 @@ namespace DapperExample.TranslationToSql.Transformations; /// internal sealed class ColumnSelectorUsageCollector : SqlTreeNodeVisitor { - private readonly HashSet _usedColumns = new(); + private readonly HashSet _usedColumns = []; private readonly ILogger _logger; public ISet UsedColumns => _usedColumns; diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs index 00bb7b0756..03692baf2d 100644 --- a/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs @@ -88,7 +88,7 @@ private Dictionary CopyTopStackElement() { if (_tablesInScopeStack.Count == 0) { - return new Dictionary(); + return []; } Dictionary topElement = _tablesInScopeStack.Peek(); @@ -98,7 +98,7 @@ private Dictionary CopyTopStackElement() private IReadOnlyDictionary> VisitSelectors( IReadOnlyDictionary> selectors, ColumnVisitMode mode) { - Dictionary> newSelectors = new(); + Dictionary> newSelectors = []; foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectors) { diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs index 36bb3f0c0a..7cffc8e29a 100644 --- a/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs @@ -76,7 +76,7 @@ public override SqlTreeNode VisitSelect(SelectNode node, ISet usedCo private IReadOnlyDictionary> VisitSelectors(SelectNode select, ISet usedColumns) { - Dictionary> newSelectors = new(); + Dictionary> newSelectors = []; foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in select.Selectors) { @@ -90,7 +90,7 @@ private IReadOnlyDictionary> Visi private List VisitTableSelectors(IEnumerable selectors, ISet usedColumns) { - List newTableSelectors = new(); + List newTableSelectors = []; foreach (SelectorNode selector in selectors) { diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs index 1b7b96c3f9..0fc42b1ba0 100644 --- a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs @@ -13,7 +13,7 @@ namespace DapperExample.TranslationToSql.TreeNodes; /// internal sealed class SelectNode : TableSourceNode { - private readonly List _columns = new(); + private readonly List _columns = []; public IReadOnlyDictionary> Selectors { get; } public WhereNode? Where { get; } diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs index 03d8353eb5..31977f1546 100644 --- a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs @@ -17,7 +17,7 @@ internal sealed class TableNode : TableSourceNode { private readonly ResourceType _resourceType; private readonly IReadOnlyDictionary _columnMappings; - private readonly List _columns = new(); + private readonly List _columns = []; public string Name => _resourceType.ClrType.Name.Pluralize(); diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs index be5e01b7a9..3c89ac3bcf 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs @@ -8,10 +8,7 @@ public sealed class NonJsonApiController : ControllerBase [HttpGet] public IActionResult Get() { - string[] result = - { - "Welcome!" - }; + string[] result = ["Welcome!"]; return Ok(result); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs index 59247532b9..778119c6be 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs @@ -4,7 +4,7 @@ internal abstract class RotatingList { public static RotatingList Create(int count, Func createElement) { - List elements = new(); + List elements = []; for (int index = 0; index < count; index++) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index 85b0928412..31aee37585 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -37,11 +37,10 @@ public override SortExpression OnApplySort(SortExpression? existingSort) private SortExpression GetDefaultSortOrder() { - return CreateSortExpressionFromLambda(new PropertySortOrder - { + return CreateSortExpressionFromLambda([ (todoItem => todoItem.Priority, ListSortDirection.Ascending), (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) - }); + ]); } public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) diff --git a/src/Examples/NoEntityFrameworkExample/Data/Database.cs b/src/Examples/NoEntityFrameworkExample/Data/Database.cs index eee64653ee..5d0c00eb17 100644 --- a/src/Examples/NoEntityFrameworkExample/Data/Database.cs +++ b/src/Examples/NoEntityFrameworkExample/Data/Database.cs @@ -48,9 +48,9 @@ static Database() Name = "Business" }; - TodoItems = new List - { - new() + TodoItems = + [ + new TodoItem { Id = ++todoItemIndex, Description = "Make homework", @@ -63,7 +63,7 @@ static Database() personalTag } }, - new() + new TodoItem { Id = ++todoItemIndex, Description = "Book vacation", @@ -75,7 +75,7 @@ static Database() personalTag } }, - new() + new TodoItem { Id = ++todoItemIndex, Description = "Cook dinner", @@ -89,7 +89,7 @@ static Database() personalTag } }, - new() + new TodoItem { Id = ++todoItemIndex, Description = "Check emails", @@ -102,20 +102,20 @@ static Database() businessTag } } - }; + ]; - Tags = new List - { + Tags = + [ personalTag, familyTag, businessTag - }; + ]; - People = new List - { + People = + [ john, jane - }; + ]; foreach (Tag tag in Tags) { diff --git a/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs index 35b2a29e9e..67b04d1d3d 100644 --- a/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs +++ b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs @@ -20,14 +20,14 @@ public sealed class NullSafeExpressionRewriter : ExpressionVisitor private static readonly ConstantExpression Int32MinValueConstant = Expression.Constant(int.MinValue, typeof(int)); private static readonly ExpressionType[] ComparisonExpressionTypes = - { + [ ExpressionType.LessThan, ExpressionType.LessThanOrEqual, ExpressionType.GreaterThan, ExpressionType.GreaterThanOrEqual, ExpressionType.Equal // ExpressionType.NotEqual is excluded because WhereClauseBuilder never produces that. - }; + ]; private readonly Stack _callStack = new(); diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 29bd5559b1..47542def56 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -9,8 +9,8 @@ namespace JsonApiDotNetCore.Configuration; [PublicAPI] public sealed class ResourceType { - private readonly Dictionary _fieldsByPublicName = new(); - private readonly Dictionary _fieldsByPropertyName = new(); + private readonly Dictionary _fieldsByPublicName = []; + private readonly Dictionary _fieldsByPropertyName = []; private readonly Lazy> _lazyAllConcreteDerivedTypes; /// @@ -273,7 +273,7 @@ private static IReadOnlySet GetAttributesInTypeOrDerived(Resource // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported. // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords - HashSet attributesInDerivedTypes = new(); + HashSet attributesInDerivedTypes = []; foreach (AttrAttribute attributeInDerivedType in resourceType.DirectlyDerivedTypes .Select(derivedType => GetAttributesInTypeOrDerived(derivedType, publicName)).SelectMany(attributesInDerivedType => attributesInDerivedType)) @@ -300,7 +300,7 @@ private static IReadOnlySet GetRelationshipsInTypeOrDeriv // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported. // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords - HashSet relationshipsInDerivedTypes = new(); + HashSet relationshipsInDerivedTypes = []; foreach (RelationshipAttribute relationshipInDerivedType in resourceType.DirectlyDerivedTypes .Select(derivedType => GetRelationshipsInTypeOrDerived(derivedType, publicName)) diff --git a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs index b7fd934fbe..2e8f9acfc6 100644 --- a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs @@ -11,25 +11,16 @@ public static IEnumerable AsEnumerable(this T element) public static T[] AsArray(this T element) { - return new[] - { - element - }; + return [element]; } public static List AsList(this T element) { - return new List - { - element - }; + return [element]; } public static HashSet AsHashSet(this T element) { - return new HashSet - { - element - }; + return [element]; } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index 0e9f5d753f..a220d96e01 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Configuration; internal sealed class ResourceDescriptorAssemblyCache { private readonly TypeLocator _typeLocator = new(); - private readonly Dictionary?> _resourceDescriptorsPerAssembly = new(); + private readonly Dictionary?> _resourceDescriptorsPerAssembly = []; public void RegisterAssembly(Assembly assembly) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 884b8ccd79..e763ec2ae0 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -13,8 +13,8 @@ public sealed class ResourceGraph : IResourceGraph private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); private readonly IReadOnlySet _resourceTypeSet; - private readonly Dictionary _resourceTypesByClrType = new(); - private readonly Dictionary _resourceTypesByPublicName = new(); + private readonly Dictionary _resourceTypesByClrType = []; + private readonly Dictionary _resourceTypesByPublicName = []; public ResourceGraph(IReadOnlySet resourceTypeSet) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 4ea0cb30e6..b0ebd8bb60 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -17,7 +17,7 @@ public class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; - private readonly Dictionary _resourceTypesByClrType = new(); + private readonly Dictionary _resourceTypesByClrType = []; private readonly TypeLocator _typeLocator = new(); public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) @@ -34,7 +34,7 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { - HashSet resourceTypes = _resourceTypesByClrType.Values.ToHashSet(); + HashSet resourceTypes = [.. _resourceTypesByClrType.Values]; if (!resourceTypes.Any()) { @@ -81,7 +81,7 @@ private static void SetRelationshipTypes(ResourceGraph resourceGraph) private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph) { - Dictionary> directlyDerivedTypesPerBaseType = new(); + Dictionary> directlyDerivedTypesPerBaseType = []; foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) { @@ -93,7 +93,7 @@ private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph) if (!directlyDerivedTypesPerBaseType.ContainsKey(baseType)) { - directlyDerivedTypesPerBaseType[baseType] = new HashSet(); + directlyDerivedTypesPerBaseType[baseType] = []; } directlyDerivedTypesPerBaseType[baseType].Add(resourceType); diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index d17ddfa1ba..5d2ac7190b 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -16,8 +16,8 @@ namespace JsonApiDotNetCore.Configuration; [PublicAPI] public sealed class ServiceDiscoveryFacade { - internal static readonly HashSet ServiceUnboundInterfaces = new() - { + internal static readonly HashSet ServiceUnboundInterfaces = + [ typeof(IResourceService<,>), typeof(IResourceCommandService<,>), typeof(IResourceQueryService<,>), @@ -31,19 +31,16 @@ public sealed class ServiceDiscoveryFacade typeof(ISetRelationshipService<,>), typeof(IDeleteService<,>), typeof(IRemoveFromRelationshipService<,>) - }; + ]; - internal static readonly HashSet RepositoryUnboundInterfaces = new() - { + internal static readonly HashSet RepositoryUnboundInterfaces = + [ typeof(IResourceRepository<,>), typeof(IResourceWriteRepository<,>), typeof(IResourceReadRepository<,>) - }; + ]; - internal static readonly HashSet ResourceDefinitionUnboundInterfaces = new() - { - typeof(IResourceDefinition<,>) - }; + internal static readonly HashSet ResourceDefinitionUnboundInterfaces = [typeof(IResourceDefinition<,>)]; private readonly ILogger _logger; private readonly IServiceCollection _services; diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs index 4b4d82b62b..8fc75dad4e 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -12,7 +12,7 @@ internal sealed class CascadingCodeTimer : ICodeTimer { private readonly Stopwatch _stopwatch = new(); private readonly Stack _activeScopeStack = new(); - private readonly List _completedScopes = new(); + private readonly List _completedScopes = []; static CascadingCodeTimer() { diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 50cb511b14..b5805e15dc 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -30,7 +30,7 @@ private static IEnumerable FromModelStateDictionary(IReadOnlyDictio ArgumentGuard.NotNull(modelType); ArgumentGuard.NotNull(resourceGraph); - List errorObjects = new(); + List errorObjects = []; foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, getCollectionElementTypeCallback)) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index a6ef712adf..c2df736e96 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -32,9 +32,9 @@ public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention private readonly IJsonApiOptions _options; private readonly IResourceGraph _resourceGraph; private readonly ILogger _logger; - private readonly Dictionary _registeredControllerNameByTemplate = new(); - private readonly Dictionary _resourceTypePerControllerTypeMap = new(); - private readonly Dictionary _controllerPerResourceTypeMap = new(); + private readonly Dictionary _registeredControllerNameByTemplate = []; + private readonly Dictionary _resourceTypePerControllerTypeMap = []; + private readonly Dictionary _controllerPerResourceTypeMap = []; public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, ILogger logger) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index 8b2034a374..f88cb60a86 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -46,7 +46,7 @@ private sealed class IncludeToChainsConverter : QueryExpressionVisitor _parentRelationshipStack = new(); - public List Chains { get; } = new(); + public List Chains { get; } = []; public override object? VisitInclude(IncludeExpression expression, object? argument) { diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 1af9656154..cbd6ee4b21 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -14,8 +14,8 @@ namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public class FilterParser : QueryExpressionParser, IFilterParser { - private static readonly HashSet FilterKeywords = new(new[] - { + private static readonly HashSet FilterKeywords = + [ Keywords.Not, Keywords.And, Keywords.Or, @@ -31,7 +31,7 @@ public class FilterParser : QueryExpressionParser, IFilterParser Keywords.Count, Keywords.Has, Keywords.IsType - }); + ]; private readonly IResourceFactory _resourceFactory; private readonly Stack _resourceTypeStack = new(); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs index 27fffb9467..79f286da91 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -111,8 +111,8 @@ private ICollection ParseRelationshipName(string source, IColle private static ICollection LookupRelationshipName(string relationshipName, ICollection parents, string source, int position) { - List children = new(); - HashSet relationshipsFound = new(); + List children = []; + HashSet relationshipsFound = []; foreach (IncludeTreeNode parent in parents) { diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs index 3b0793f774..81a0db533e 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -22,7 +22,7 @@ public virtual Expression ApplyInclude(IncludeExpression include, QueryClauseBui public override Expression VisitInclude(IncludeExpression expression, QueryClauseBuilderContext context) { // De-duplicate chains coming from derived relationships. - HashSet propertyPaths = new(); + HashSet propertyPaths = []; ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs index cf8a30e1db..257906b69f 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Queries.QueryableBuilding; [PublicAPI] public sealed class LambdaScopeFactory { - private readonly HashSet _namesInScope = new(); + private readonly HashSet _namesInScope = []; /// /// Finds the next unique lambda parameter name. Dispose the returned scope to release the claimed name, so it can be reused. diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs index 90255191d7..a0aa58e377 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -245,7 +245,7 @@ public IReadOnlyList GetAllFieldsMatched() current = current._parentMatch; } - List fields = new(); + List fields = []; while (matchStack.Count > 0) { diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs index 63c6c67876..653c029e46 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs @@ -69,7 +69,7 @@ public void LogMatchResult(MatchState resultState) } else { - List chain = new(resultState.FieldsMatched.Select(attribute => attribute.PublicName)); + List chain = [..resultState.FieldsMatched.Select(attribute => attribute.PublicName)]; if (resultState.FieldsRemaining != null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs index 8172bdaa95..049aac2b94 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -186,7 +186,7 @@ private MatchState MatchField(MatchState state) /// private HashSet LookupFields(ResourceType? resourceType, string publicName) { - HashSet fields = new(); + HashSet fields = []; if (resourceType != null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs index a00ec26846..cc0fa0a69e 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -28,12 +28,12 @@ internal sealed class PatternParser [Token.Field] = FieldTypes.Field }; - private static readonly HashSet QuantifierTokens = new(new[] - { + private static readonly HashSet QuantifierTokens = + [ Token.QuestionMark, Token.Plus, Token.Asterisk - }); + ]; private string _source = null!; private Queue _tokenQueue = null!; diff --git a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index fe8d35bdca..3c8d16a3f0 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -22,7 +22,7 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil private readonly IQueryStringParameterScopeParser _scopeParser; private readonly IFilterParser _filterParser; private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); - private readonly Dictionary.Builder> _filtersPerScope = new(); + private readonly Dictionary.Builder> _filtersPerScope = []; public bool AllowEmptyValue => false; diff --git a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs index 93e180275a..3364217efb 100644 --- a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -165,7 +165,7 @@ public virtual IReadOnlyCollection GetConstraints() private sealed class PaginationState { private readonly MutablePaginationEntry _globalScope = new(); - private readonly Dictionary _nestedScopes = new(); + private readonly Dictionary _nestedScopes = []; public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) { diff --git a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index c881f04dc4..3d844e4b48 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -15,7 +15,7 @@ public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQue { private readonly IJsonApiRequest _request; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly List _constraints = new(); + private readonly List _constraints = []; public bool AllowEmptyValue => false; diff --git a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 542d69b8ec..4362f95ed5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -17,7 +17,7 @@ public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQ { private readonly IQueryStringParameterScopeParser _scopeParser; private readonly ISortParser _sortParser; - private readonly List _constraints = new(); + private readonly List _constraints = []; public bool AllowEmptyValue => false; diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index cb3a0874e0..2e841873b2 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -10,8 +10,8 @@ public sealed class TargetedFields : ITargetedFields IReadOnlySet ITargetedFields.Attributes => Attributes; IReadOnlySet ITargetedFields.Relationships => Relationships; - public HashSet Attributes { get; } = new(); - public HashSet Relationships { get; } = new(); + public HashSet Attributes { get; } = []; + public HashSet Relationships { get; } = []; /// public void CopyFrom(ITargetedFields other) diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index 3d1eb5dd26..a5e647c853 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -12,7 +12,7 @@ public sealed class MetaBuilder : IMetaBuilder private readonly IJsonApiOptions _options; private readonly IResponseMeta _responseMeta; - private Dictionary _meta = new(); + private Dictionary _meta = []; public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 901c1d94fc..ab50d6f674 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -55,7 +55,7 @@ public void AttachDirectChild(ResourceObjectTreeNode treeNode) { ArgumentGuard.NotNull(treeNode); - _directChildren ??= new List(); + _directChildren ??= []; _directChildren.Add(treeNode); } @@ -63,11 +63,11 @@ public void EnsureHasRelationship(RelationshipAttribute relationship) { ArgumentGuard.NotNull(relationship); - _childrenByRelationship ??= new Dictionary>(); + _childrenByRelationship ??= []; if (!_childrenByRelationship.ContainsKey(relationship)) { - _childrenByRelationship[relationship] = new HashSet(); + _childrenByRelationship[relationship] = []; } } @@ -182,7 +182,7 @@ public IList GetResponseIncluded() } ISet primaryResourceObjectSet = GetDirectChildren().Select(node => node.ResourceObject).ToHashSet(ResourceObjectComparer.Instance); - List includes = new(); + List includes = []; foreach (ResourceObject include in visited.Select(node => node.ResourceObject)) { diff --git a/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs index 394cdecc5b..53ef375e0a 100644 --- a/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs +++ b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs @@ -18,10 +18,10 @@ public void Visitor_methods_call_default_visit() .Where(method => method.Name.StartsWith("Visit", StringComparison.Ordinal) && method.Name != "Visit").ToArray(); object?[] parameters = - { + [ null, null - }; + ]; // Act foreach (MethodInfo method in visitMethods) diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 78c4213e93..e4f16dbb1f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -15,7 +15,7 @@ namespace DiscoveryTests; public sealed class ServiceDiscoveryFacadeTests { - private readonly ServiceCollection _services = new(); + private readonly ServiceCollection _services = []; public ServiceDiscoveryFacadeTests() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 909467ad18..293b4b495c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -910,10 +910,10 @@ public async Task Cannot_add_for_unknown_IDs_in_data() RecordCompany existingCompany = _fakers.RecordCompany.Generate(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 71b4a1bf09..7e9654ab30 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -870,10 +870,10 @@ public async Task Cannot_remove_for_unknown_IDs_in_data() RecordCompany existingCompany = _fakers.RecordCompany.Generate(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index f36144ce70..bcedf104bf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -963,10 +963,10 @@ public async Task Cannot_replace_for_unknown_IDs_in_data() RecordCompany existingCompany = _fakers.RecordCompany.Generate(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index 0d8c5e1d80..10da541794 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -668,10 +668,10 @@ public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() RecordCompany existingCompany = _fakers.RecordCompany.Generate(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs index 3a99d3c015..547c008afa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs @@ -14,7 +14,7 @@ internal sealed class AuthScopeSet public const string ScopesHeaderName = "X-Auth-Scopes"; - private readonly Dictionary _scopes = new(); + private readonly Dictionary _scopes = []; public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 1e95efa717..aa12e5ceb3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -39,7 +39,7 @@ private static string FromHexString(string hexString) bytes.Add(bt); } - char[] chars = Encoding.ASCII.GetChars(bytes.ToArray()); + char[] chars = Encoding.ASCII.GetChars([.. bytes]); return new string(chars); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index 9365ff08a0..d1772330ca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -11,7 +11,7 @@ public abstract class MessagingGroupDefinition : HitCountingResourceDefinition _userSet; private readonly DbSet _groupSet; - private readonly List _pendingMessages = new(); + private readonly List _pendingMessages = []; private string? _beforeGroupName; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs index 499c572b88..cb8e84e8ba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; public abstract class MessagingUserDefinition : HitCountingResourceDefinition { private readonly DbSet _userSet; - private readonly List _pendingMessages = new(); + private readonly List _pendingMessages = []; private string? _beforeLoginName; private string? _beforeDisplayName; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs index 170a8eb194..59a17ab95a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs @@ -8,11 +8,7 @@ public sealed class NonJsonApiController : ControllerBase [HttpGet] public IActionResult Get() { - string[] result = - { - "Welcome!" - }; - + string[] result = ["Welcome!"]; return Ok(result); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index 0668d32644..76087dc31f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -10,8 +10,8 @@ internal sealed class SumFilterParser : FilterParser { private static readonly FieldChainPattern SingleToManyRelationshipChain = FieldChainPattern.Parse("M"); - private static readonly HashSet NumericTypes = new(new[] - { + private static readonly HashSet NumericTypes = + [ typeof(sbyte), typeof(byte), typeof(short), @@ -23,7 +23,7 @@ internal sealed class SumFilterParser : FilterParser typeof(float), typeof(double), typeof(decimal) - }); + ]; public SumFilterParser(IResourceFactory resourceFactory) : base(resourceFactory) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs index e2ef670d71..0c6d016081 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.SparseFieldSets; public sealed class ResourceCaptureStore { - internal List Resources { get; } = new(); + internal List Resources { get; } = []; internal void Add(IEnumerable resources) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs index 55b5b53918..6146e04367 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs @@ -26,11 +26,10 @@ public override SortExpression OnApplySort(SortExpression? existingSort) private SortExpression GetDefaultSortOrder() { - return CreateSortExpressionFromLambda(new PropertySortOrder - { + return CreateSortExpressionFromLambda([ (star => star.SolarMass, ListSortDirection.Descending), (star => star.SolarRadius, ListSortDirection.Descending) - }); + ]); } public override PaginationExpression OnApplyPagination(PaginationExpression? existingPagination) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/WheelSortDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/WheelSortDefinition.cs index e66e05bc64..7785622e9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/WheelSortDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/WheelSortDefinition.cs @@ -61,12 +61,11 @@ private SortExpression CreateSortFromExpressionSyntax() private SortExpression CreateSortFromLambdaSyntax() { - return CreateSortExpressionFromLambda(new PropertySortOrder - { + return CreateSortExpressionFromLambda([ (wheel => (wheel as ChromeWheel)!.PaintColor, ListSortDirection.Ascending), (wheel => ((CarbonWheel)wheel).HasTube, ListSortDirection.Descending), (wheel => ((GasolineEngine)((Car)wheel.Vehicle!).Engine).Cylinders.Count, ListSortDirection.Ascending), (wheel => wheel.Id, ListSortDirection.Ascending) - }); + ]); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs index be5f72b3f8..ca3b246f3f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; internal sealed class SerializationFakers : FakerContainer { private static readonly TimeSpan[] MeetingDurations = - { + [ TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(45), TimeSpan.FromMinutes(60) - }; + ]; private readonly Lazy> _lazyMeetingFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index b7f55bee8e..bb2df33c33 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -589,7 +589,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - string[] meetingIds = meeting.Attendees.Select(attendee => attendee.StringId!).OrderBy(id => id).ToArray(); + string[] meetingIds = [.. meeting.Attendees.Select(attendee => attendee.StringId!).OrderBy(id => id)]; responseDocument.Should().BeJson(@"{ ""links"": { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs index b30c4c6158..ec4da0702a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCoreTests.UnitTests.Queries; internal sealed class TestableQueryExpressionRewriter : QueryExpressionRewriter { - public List ExpressionsVisited { get; } = new(); + public List ExpressionsVisited { get; } = []; public override QueryExpression DefaultVisit(QueryExpression expression, object? argument) { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs index 8b919a87cc..337940b75c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs @@ -24,24 +24,23 @@ public void Can_convert_chain_of_ToOne_relationships_ending_in_attribute() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - SortExpression expression = resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { + SortExpression expression = resourceDefinition.GetSortExpressionFromLambda([ (file => file.Content, ListSortDirection.Descending), (file => file.Name, ListSortDirection.Ascending), (file => file.Length, ListSortDirection.Ascending), (file => file.Parent.Name, ListSortDirection.Ascending), (file => file.Parent.Parent.Name, ListSortDirection.Ascending) - }); + ]); // Assert string[] expected = - { + [ "-fileEntries:content", "fileEntries:name", "fileEntries:length", "fileSystemEntries:parent.fileSystemEntries:name", "fileSystemEntries:parent.fileSystemEntries:parent.fileSystemEntries:name" - }; + ]; expression.ToFullString().Should().Be(string.Join(',', expected)); } @@ -55,25 +54,24 @@ public void Can_convert_chain_of_ToOne_relationships_ending_in_count_of_ToMany_r var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - SortExpression expression = resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { + SortExpression expression = resourceDefinition.GetSortExpressionFromLambda([ (directory => directory.Subdirectories.Count, ListSortDirection.Ascending), // ReSharper disable once UseCollectionCountProperty (directory => directory.Files.Count(), ListSortDirection.Descending), (directory => directory.Children.Count, ListSortDirection.Ascending), (directory => directory.Parent.Children.Count, ListSortDirection.Ascending), (directory => directory.Parent.Parent.Children.Count, ListSortDirection.Ascending) - }); + ]); // Assert string[] expected = - { + [ "count(directoryEntries:subdirectories)", "-count(directoryEntries:files)", "count(fileSystemEntries:children)", "count(fileSystemEntries:parent.fileSystemEntries:children)", "count(fileSystemEntries:parent.fileSystemEntries:parent.fileSystemEntries:children)" - }; + ]; expression.ToFullString().Should().Be(string.Join(',', expected)); } @@ -87,24 +85,23 @@ public void Can_convert_chain_with_conversion_to_derived_types() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - SortExpression expression = resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { + SortExpression expression = resourceDefinition.GetSortExpressionFromLambda([ (entry => ((FileEntry)entry).Content, ListSortDirection.Ascending), (entry => (entry.Parent as FileEntry)!.Content, ListSortDirection.Ascending), (entry => ((DirectoryEntry)entry).Subdirectories.Count, ListSortDirection.Ascending), (entry => ((DirectoryEntry)((FileEntry)entry).Parent).Files.Count, ListSortDirection.Ascending), (entry => ((DirectoryEntry)(FileSystemEntry)(FileEntry)entry).Name, ListSortDirection.Descending) - }); + ]); // Assert string[] expected = - { + [ "fileEntries:content", "fileSystemEntries:parent.fileEntries:content", "count(directoryEntries:subdirectories)", "count(fileSystemEntries:parent.directoryEntries:files)", "-directoryEntries:name" - }; + ]; expression.ToFullString().Should().Be(string.Join(',', expected)); } @@ -118,10 +115,7 @@ public void Cannot_convert_unexposed_attribute() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (file => file.IsCompressed, ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(file => file.IsCompressed, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -143,10 +137,7 @@ public void Cannot_convert_unexposed_ToMany_relationship() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (file => file.Content.Length, ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(file => file.Content.Length, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -168,10 +159,7 @@ public void Cannot_convert_unexposed_ToOne_relationship() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (file => file.ParentDirectory!.Name, ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(file => file.ParentDirectory!.Name, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -193,10 +181,7 @@ public void Cannot_convert_unexposed_resource_type() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (entry => ((FileEntry)entry).Content, ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(entry => ((FileEntry)entry).Content, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -218,10 +203,7 @@ public void Cannot_convert_count_with_predicate() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (directory => directory.Files.Count(_ => true), ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(directory => directory.Files.Count(_ => true), ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -243,10 +225,7 @@ public void Cannot_convert_null_selector() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (_ => null, ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(_ => null, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -267,10 +246,7 @@ public void Cannot_convert_self_selector() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (entry => entry, ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(entry => entry, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -291,10 +267,8 @@ public void Cannot_convert_conditional_operator() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (file => file.IsCompressed ? file.Content : file.Length, ListSortDirection.Ascending) - }); + Action action = () => + resourceDefinition.GetSortExpressionFromLambda([(file => file.IsCompressed ? file.Content : file.Length, ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -315,10 +289,7 @@ public void Cannot_convert_concatenation_operator() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { - (file => $"{file.Name}:{file.Content}", ListSortDirection.Ascending) - }); + Action action = () => resourceDefinition.GetSortExpressionFromLambda([(file => $"{file.Name}:{file.Content}", ListSortDirection.Ascending)]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; @@ -339,14 +310,13 @@ public void Cannot_convert_projection_into_anonymous_type() var resourceDefinition = new WrapperResourceDefinition(resourceGraph); // Act - Action action = () => resourceDefinition.GetSortExpressionFromLambda(new JsonApiResourceDefinition.PropertySortOrder - { + Action action = () => resourceDefinition.GetSortExpressionFromLambda([ (file => new { file.Length, file.Content }, ListSortDirection.Ascending) - }); + ]); // Assert JsonApiException exception = action.Should().ThrowExactly().Which; diff --git a/test/SourceGeneratorTests/CompilationBuilder.cs b/test/SourceGeneratorTests/CompilationBuilder.cs index b4830e25f3..90c0d6e396 100644 --- a/test/SourceGeneratorTests/CompilationBuilder.cs +++ b/test/SourceGeneratorTests/CompilationBuilder.cs @@ -17,8 +17,8 @@ internal sealed class CompilationBuilder ["CS1701"] = ReportDiagnostic.Suppress }); - private readonly HashSet _syntaxTrees = new(); - private readonly HashSet _references = new(); + private readonly HashSet _syntaxTrees = []; + private readonly HashSet _references = []; public Compilation Build() { diff --git a/test/SourceGeneratorTests/SourceCodeBuilder.cs b/test/SourceGeneratorTests/SourceCodeBuilder.cs index e734604e31..05bdc031a4 100644 --- a/test/SourceGeneratorTests/SourceCodeBuilder.cs +++ b/test/SourceGeneratorTests/SourceCodeBuilder.cs @@ -4,7 +4,7 @@ namespace SourceGeneratorTests; internal sealed class SourceCodeBuilder { - private readonly HashSet _namespaceImports = new(); + private readonly HashSet _namespaceImports = []; private string? _namespace; private string? _code; diff --git a/test/TestBuildingBlocks/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs index c946ede4ed..9d8e74c5b3 100644 --- a/test/TestBuildingBlocks/FakeLoggerFactory.cs +++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs @@ -31,7 +31,7 @@ public sealed class FakeLogger : ILogger private readonly LogLevel _minimumLevel; private readonly object _lockObject = new(); - private readonly List _messages = new(); + private readonly List _messages = []; public FakeLogger(LogLevel minimumLevel) { diff --git a/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs index 2d6886e00c..bb043bd0c5 100644 --- a/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs +++ b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs @@ -3,11 +3,11 @@ namespace TestBuildingBlocks; public sealed class IgnoreLineEndingsComparer : IEqualityComparer { private static readonly string[] LineSeparators = - { + [ "\r\n", "\r", "\n" - }; + ]; public static readonly IgnoreLineEndingsComparer Instance = new(); From ca91b1f9cf727a82ddbf51a5ca5107389a7d2a33 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:12:07 +0100 Subject: [PATCH 39/53] Resharper: remove redundant body --- .../TranslationToSql/TreeNodes/FilterNode.cs | 4 +--- .../TranslationToSql/TreeNodes/SqlValueNode.cs | 4 +--- .../Controllers/EmployeesController.cs | 4 +--- .../Annotations/EagerLoadAttribute.netstandard.cs | 4 +--- .../Annotations/NoResourceAttribute.shared.cs | 4 +--- .../Processors/IAddToRelationshipProcessor.cs | 4 +--- .../AtomicOperations/Processors/ICreateProcessor.cs | 4 +--- .../AtomicOperations/Processors/IDeleteProcessor.cs | 4 +--- .../Processors/IRemoveFromRelationshipProcessor.cs | 4 +--- .../Processors/ISetRelationshipProcessor.cs | 4 +--- .../AtomicOperations/Processors/IUpdateProcessor.cs | 4 +--- .../Annotations/DisableRoutingConventionAttribute.cs | 4 +--- .../IAsyncConvertEmptyActionResultFilter.cs | 4 +--- .../Middleware/IAsyncJsonApiExceptionFilter.cs | 4 +--- .../Middleware/IAsyncQueryStringActionFilter.cs | 4 +--- .../Middleware/IJsonApiInputFormatter.cs | 4 +--- .../Middleware/IJsonApiOutputFormatter.cs | 4 +--- .../Middleware/IJsonApiRoutingConvention.cs | 4 +--- .../Queries/Expressions/IdentifierExpression.cs | 4 +--- .../IFilterQueryStringParameterReader.cs | 4 +--- .../IIncludeQueryStringParameterReader.cs | 4 +--- .../IPaginationQueryStringParameterReader.cs | 4 +--- .../IResourceDefinitionQueryableParameterReader.cs | 4 +--- .../QueryStrings/ISortQueryStringParameterReader.cs | 4 +--- .../ISparseFieldSetQueryStringParameterReader.cs | 4 +--- .../Repositories/IResourceRepository.cs | 4 +--- .../Resources/JsonApiResourceDefinition.cs | 4 +--- .../Resources/QueryStringParameterHandlers.cs | 4 +--- .../Services/IResourceCommandService.cs | 4 +--- .../Services/IResourceQueryService.cs | 4 +--- src/JsonApiDotNetCore/Services/IResourceService.cs | 4 +--- test/DiscoveryTests/PrivateResource.cs | 4 +--- .../HostingInIIS/PaintingsController.cs | 4 +--- .../MultiTenancy/WebProductsController.cs | 4 +--- .../MultiTenancy/WebShopsController.cs | 4 +--- .../RestrictedControllers/PillowsController.cs | 4 +--- .../RestrictedControllers/SofasController.cs | 4 +--- .../DependencyContainerRegistrationTests.cs | 4 +--- .../ServiceCollectionExtensionsTests.cs | 12 +++--------- .../UnitTests/Links/LinkInclusionTests.cs | 4 +--- .../UnitTests/Middleware/JsonApiMiddlewareTests.cs | 4 +--- .../ResourceGraph/ResourceGraphBuilderTests.cs | 8 ++------ .../TypeConversion/RuntimeTypeConverterTests.cs | 12 +++--------- test/UnitTests/Graph/BaseType.cs | 4 +--- test/UnitTests/Graph/DerivedType.cs | 4 +--- test/UnitTests/Graph/IGenericInterface.cs | 4 +--- test/UnitTests/Graph/Implementation.cs | 4 +--- test/UnitTests/Graph/Model.cs | 4 +--- .../Models/ResourceConstructionExpressionTests.cs | 4 +--- 49 files changed, 54 insertions(+), 162 deletions(-) diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs index 1874fc16e4..92a24bac6d 100644 --- a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs @@ -3,6 +3,4 @@ namespace DapperExample.TranslationToSql.TreeNodes; /// /// Represents the base type for filters that return a boolean value. /// -internal abstract class FilterNode : SqlTreeNode -{ -} +internal abstract class FilterNode : SqlTreeNode; diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs index bacbd5672f..da1b097757 100644 --- a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs @@ -3,6 +3,4 @@ namespace DapperExample.TranslationToSql.TreeNodes; /// /// Represents the base type for values, such as parameters, column references and NULL. /// -internal abstract class SqlValueNode : SqlTreeNode -{ -} +internal abstract class SqlValueNode : SqlTreeNode; diff --git a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs index f9d5595123..5bae37f9b9 100644 --- a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs +++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs @@ -5,6 +5,4 @@ namespace DatabasePerTenantExample.Controllers; [DisableRoutingConvention] [Route("api/{tenantName}/employees")] -partial class EmployeesController -{ -} +partial class EmployeesController; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs index 47052a078c..3083d7f436 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Resources.Annotations; /// [PublicAPI] [AttributeUsage(AttributeTargets.Property)] -public sealed class EagerLoadAttribute : Attribute -{ -} +public sealed class EagerLoadAttribute : Attribute; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs index 8be53b0d03..02d19761d7 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs @@ -8,6 +8,4 @@ namespace JsonApiDotNetCore.Resources.Annotations; /// [PublicAPI] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class NoResourceAttribute : Attribute -{ -} +public sealed class NoResourceAttribute : Attribute; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index 8b9990342a..91d23e3358 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IAddToRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index 9fd1de2186..6cc04043f3 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface ICreateProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs index 67627cd8c0..42f5f71c14 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IDeleteProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs index 6492c992f1..2dc7bdb17d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -12,6 +12,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IRemoveFromRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index dd950d203d..7928aa76b0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface ISetRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs index 6051837749..77b83f65f7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -17,6 +17,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IUpdateProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs index 34e1132789..19df79dc2b 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs @@ -11,6 +11,4 @@ namespace JsonApiDotNetCore.Controllers.Annotations; /// ]]> [PublicAPI] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class DisableRoutingConventionAttribute : Attribute -{ -} +public sealed class DisableRoutingConventionAttribute : Attribute; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs index 87676657e5..3116b45d40 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs @@ -14,6 +14,4 @@ namespace JsonApiDotNetCore.Middleware; /// https://github.com/dotnet/aspnetcore/issues/16969 /// [PublicAPI] -public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter -{ -} +public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs index fb0cbb9b17..1fc4e136af 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide exception filter that invokes for JSON:API requests. /// [PublicAPI] -public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter -{ -} +public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs index 0c9cbfbb29..d3df469f64 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide entry point for processing JSON:API request query strings. /// [PublicAPI] -public interface IAsyncQueryStringActionFilter : IAsyncActionFilter -{ -} +public interface IAsyncQueryStringActionFilter : IAsyncActionFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs index cb5fe76167..7879530650 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide entry point for reading JSON:API request bodies. /// [PublicAPI] -public interface IJsonApiInputFormatter : IInputFormatter -{ -} +public interface IJsonApiInputFormatter : IInputFormatter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs index bc7213ebed..ff285e26e7 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide entry point for writing JSON:API response bodies. /// [PublicAPI] -public interface IJsonApiOutputFormatter : IOutputFormatter -{ -} +public interface IJsonApiOutputFormatter : IOutputFormatter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs index 86b68a9b03..a83156f33f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Service for specifying which routing convention to use. This can be overridden to customize the relation between controllers and mapped routes. /// [PublicAPI] -public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping -{ -} +public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs index 5b7d63ef96..5a978b54f6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -3,6 +3,4 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// /// 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 -{ -} +public abstract class IdentifierExpression : QueryExpression; diff --git a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs index b9a7fb8c6b..4fdefe2124 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'filter' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs index 1993d249fc..822df2ee68 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'include' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs index 198bff6ff8..56141e5615 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'page' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs index 9ef114e4b3..965eb2d884 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs @@ -9,6 +9,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// query constraints from it. /// [PublicAPI] -public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs index 5cb221a399..763d1a67f1 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'sort' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs index e943121ecc..1f0bdaf90f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'fields' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index 26b7c4513e..218a09cb93 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -14,6 +14,4 @@ namespace JsonApiDotNetCore.Repositories; /// [PublicAPI] public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index f6653b9bdb..f069c155e8 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -177,7 +177,5 @@ public virtual void OnSerialize(TResource resource) /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. /// - public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> - { - } + public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)>; } diff --git a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs index 52c9f7d71a..b48c46e26e 100644 --- a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs +++ b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs @@ -6,6 +6,4 @@ namespace JsonApiDotNetCore.Resources; /// This is an alias type intended to simplify the implementation's method signature. See /// for usage details. /// -public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>> -{ -} +public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>>; diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 6c6dc408c9..7bc47b6c20 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -14,6 +14,4 @@ namespace JsonApiDotNetCore.Services; public interface IResourceCommandService : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, IDeleteService, IRemoveFromRelationshipService - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs index 7c9d32071f..b2de9b03fc 100644 --- a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs @@ -13,6 +13,4 @@ namespace JsonApiDotNetCore.Services; /// public interface IResourceQueryService : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/IResourceService.cs b/src/JsonApiDotNetCore/Services/IResourceService.cs index 87637e53a2..2a75be7151 100644 --- a/src/JsonApiDotNetCore/Services/IResourceService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -12,6 +12,4 @@ namespace JsonApiDotNetCore.Services; /// The resource identifier type. /// public interface IResourceService : IResourceCommandService, IResourceQueryService - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/test/DiscoveryTests/PrivateResource.cs b/test/DiscoveryTests/PrivateResource.cs index facbfb6c35..9ad2daef51 100644 --- a/test/DiscoveryTests/PrivateResource.cs +++ b/test/DiscoveryTests/PrivateResource.cs @@ -6,6 +6,4 @@ namespace DiscoveryTests; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class PrivateResource : Identifiable -{ -} +public sealed class PrivateResource : Identifiable; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs index dc4c27ca54..c27e628642 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -5,6 +5,4 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; [DisableRoutingConvention] [Route("custom/path/to/paintings-of-the-world")] -partial class PaintingsController -{ -} +partial class PaintingsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs index bdc7068a3d..4fe81d3fe3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -5,6 +5,4 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; [DisableRoutingConvention] [Route("{countryCode}/products")] -partial class WebProductsController -{ -} +partial class WebProductsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs index ee31954740..1e0e53d0f6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -5,6 +5,4 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; [DisableRoutingConvention] [Route("{countryCode}/shops")] -partial class WebShopsController -{ -} +partial class WebShopsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsController.cs index e260788905..16ef346e70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsController.cs @@ -3,6 +3,4 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers; [DisableQueryString("skipCache")] -partial class PillowsController -{ -} +partial class PillowsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasController.cs index d6d2d66fa8..03e0d48665 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasController.cs @@ -4,6 +4,4 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers; [DisableQueryString(JsonApiQueryStringParameters.Sort | JsonApiQueryStringParameters.Page)] -partial class SofasController -{ -} +partial class SofasController; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs index 2e9f74add6..0289585dfb 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs @@ -153,9 +153,7 @@ public SomeSingletonService(SomeScopedService scopedService) } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class SomeScopedService - { - } + private sealed class SomeScopedService; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] private sealed class CircularServiceA diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs index d4002a1932..0f7e8b0b09 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs @@ -164,14 +164,10 @@ public void Can_register_resource_definition_for_Id_type_Guid() provider.GetRequiredService(typeof(IResourceDefinition)).Should().BeOfType(); } - private sealed class ResourceOfInt32 : Identifiable - { - } + private sealed class ResourceOfInt32 : Identifiable; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class ResourceOfGuid : Identifiable - { - } + private sealed class ResourceOfGuid : Identifiable; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] private sealed class ResourceServiceOfInt32 : IResourceService @@ -584,7 +580,5 @@ public TestDbContext(DbContextOptions options) } [UsedImplicitly(ImplicitUseKindFlags.Access)] - private sealed class Person : Identifiable - { - } + private sealed class Person : Identifiable; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 03cb0e2a4c..392d61709a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -372,9 +372,7 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR } } - private sealed class ExampleResource : Identifiable - { - } + private sealed class ExampleResource : Identifiable; private sealed class FakeHttpContextAccessor : IHttpContextAccessor { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs index a57f5ff5b0..d11eb53a19 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -172,9 +172,7 @@ public enum IsCollection } [UsedImplicitly(ImplicitUseTargetFlags.Itself)] - private sealed class Person : Identifiable - { - } + private sealed class Person : Identifiable; [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class ItemTag : Identifiable diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 11ae36cd72..ebce906b9d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -428,15 +428,11 @@ private sealed class ResourceWithoutId : IIdentifiable } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class NonResource - { - } + private sealed class NonResource; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] [NoResource] - private sealed class NonResourceWithSuppression - { - } + private sealed class NonResourceWithSuppression; // ReSharper disable once ClassCanBeSealed.Global [UsedImplicitly(ImplicitUseTargetFlags.Members)] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs index f633bb5b62..4fbe4f3d22 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs @@ -150,15 +150,9 @@ public void Returns_default_value_for_empty_string(Type type, object expectedVal result.Should().Be(expectedValue); } - private interface IFace - { - } + private interface IFace; - private class BaseType : IFace - { - } + private class BaseType : IFace; - private sealed class DerivedType : BaseType - { - } + private sealed class DerivedType : BaseType; } diff --git a/test/UnitTests/Graph/BaseType.cs b/test/UnitTests/Graph/BaseType.cs index 8fdbe4f4ae..3343b4ef65 100644 --- a/test/UnitTests/Graph/BaseType.cs +++ b/test/UnitTests/Graph/BaseType.cs @@ -2,6 +2,4 @@ namespace UnitTests.Graph; -internal class BaseType -{ -} +internal class BaseType; diff --git a/test/UnitTests/Graph/DerivedType.cs b/test/UnitTests/Graph/DerivedType.cs index 4453c7b56a..9f2aca9ced 100644 --- a/test/UnitTests/Graph/DerivedType.cs +++ b/test/UnitTests/Graph/DerivedType.cs @@ -1,5 +1,3 @@ namespace UnitTests.Graph; -internal sealed class DerivedType : BaseType -{ -} +internal sealed class DerivedType : BaseType; diff --git a/test/UnitTests/Graph/IGenericInterface.cs b/test/UnitTests/Graph/IGenericInterface.cs index c9fdd83e18..aa76ce34f7 100644 --- a/test/UnitTests/Graph/IGenericInterface.cs +++ b/test/UnitTests/Graph/IGenericInterface.cs @@ -2,6 +2,4 @@ namespace UnitTests.Graph; -internal interface IGenericInterface -{ -} +internal interface IGenericInterface; diff --git a/test/UnitTests/Graph/Implementation.cs b/test/UnitTests/Graph/Implementation.cs index 5e851f4afe..e098482049 100644 --- a/test/UnitTests/Graph/Implementation.cs +++ b/test/UnitTests/Graph/Implementation.cs @@ -1,5 +1,3 @@ namespace UnitTests.Graph; -internal sealed class Implementation : IGenericInterface -{ -} +internal sealed class Implementation : IGenericInterface; diff --git a/test/UnitTests/Graph/Model.cs b/test/UnitTests/Graph/Model.cs index ad9608a946..d0ba3f4eee 100644 --- a/test/UnitTests/Graph/Model.cs +++ b/test/UnitTests/Graph/Model.cs @@ -2,6 +2,4 @@ namespace UnitTests.Graph; -internal sealed class Model : Identifiable -{ -} +internal sealed class Model : Identifiable; diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index aca0d91db1..277af5f013 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -41,9 +41,7 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() .WithMessage($"Failed to create an instance of '{typeof(ResourceWithStringConstructor).FullName}': Parameter 'text' could not be resolved."); } - private sealed class ResourceWithoutConstructor : Identifiable - { - } + private sealed class ResourceWithoutConstructor : Identifiable; [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class ResourceWithStringConstructor : Identifiable From 3e1b27b6c4723b08e827904e9ffdd30f8fb71383 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:15:33 +0100 Subject: [PATCH 40/53] Resharper: Replace async method with Task return --- WarningSeverities.DotSettings | 1 + .../Repositories/DapperRepository.cs | 4 +- .../AtomicOperations/OperationsProcessor.cs | 4 +- .../Controllers/JsonApiController.cs | 40 +++++++++---------- .../JsonApiOperationsController.cs | 4 +- .../Middleware/JsonApiOutputFormatter.cs | 4 +- .../Serialization/Response/JsonApiWriter.cs | 8 ++-- .../Services/JsonApiResourceService.cs | 12 +++--- .../AtomicOperations/AtomicOperationsTests.cs | 4 +- .../CreateMusicTrackOperationsController.cs | 4 +- ...micSerializationResourceDefinitionTests.cs | 4 +- .../Transactions/AtomicRollbackTests.cs | 8 ++-- .../Meta/ResponseMetaTests.cs | 4 +- .../Meta/TopLevelCountTests.cs | 4 +- .../Microservices/MessagingGroupDefinition.cs | 4 +- .../Microservices/MessagingUserDefinition.cs | 4 +- .../OutboxTests.Group.cs | 4 +- .../OutboxTests.User.cs | 4 +- .../SoftDeletionAwareResourceService.cs | 10 ++--- .../ZeroKeys/EmptyGuidAsKeyTests.cs | 4 +- .../ZeroKeys/ZeroAsKeyTests.cs | 4 +- .../TestBuildingBlocks/DbContextExtensions.cs | 8 ++-- test/TestBuildingBlocks/IntegrationTest.cs | 28 ++++++------- .../TestBuildingBlocks/QueryableExtensions.cs | 4 +- 24 files changed, 89 insertions(+), 90 deletions(-) diff --git a/WarningSeverities.DotSettings b/WarningSeverities.DotSettings index b5e5ca9c42..37457deebc 100644 --- a/WarningSeverities.DotSettings +++ b/WarningSeverities.DotSettings @@ -163,6 +163,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING diff --git a/src/Examples/DapperExample/Repositories/DapperRepository.cs b/src/Examples/DapperExample/Repositories/DapperRepository.cs index bbbeda2ea3..c263ad7767 100644 --- a/src/Examples/DapperExample/Repositories/DapperRepository.cs +++ b/src/Examples/DapperExample/Repositories/DapperRepository.cs @@ -161,7 +161,7 @@ public async Task> GetAsync(QueryLayer queryLayer } /// - public async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { var queryLayer = new QueryLayer(ResourceType) { @@ -173,7 +173,7 @@ public async Task CountAsync(FilterExpression? filter, CancellationToken ca CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); LogSqlCommand(sqlCommand); - return await ExecuteQueryAsync(async connection => await connection.ExecuteScalarAsync(sqlCommand), cancellationToken); + return ExecuteQueryAsync(connection => connection.ExecuteScalarAsync(sqlCommand), cancellationToken); } /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index cf1cdd7b65..9e78fcfcbe 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -99,7 +99,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso return results; } - protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) + protected virtual Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -108,7 +108,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _targetedFields.CopyFrom(operation.TargetedFields); _request.CopyFrom(operation.Request); - return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); + return _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); } protected void TrackLocalIdsForOperation(OperationContainer operation) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 091bbee47b..19c679404f 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -41,77 +41,77 @@ protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGrap /// [HttpGet] [HttpHead] - public override async Task GetAsync(CancellationToken cancellationToken) + public override Task GetAsync(CancellationToken cancellationToken) { - return await base.GetAsync(cancellationToken); + return base.GetAsync(cancellationToken); } /// [HttpGet("{id}")] [HttpHead("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) + public override Task GetAsync(TId id, CancellationToken cancellationToken) { - return await base.GetAsync(id, cancellationToken); + return base.GetAsync(id, cancellationToken); } /// [HttpGet("{id}/{relationshipName}")] [HttpHead("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public override Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); + return base.GetSecondaryAsync(id, relationshipName, cancellationToken); } /// [HttpGet("{id}/relationships/{relationshipName}")] [HttpHead("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public override Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); + return base.GetRelationshipAsync(id, relationshipName, cancellationToken); } /// [HttpPost] - public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + public override Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) { - return await base.PostAsync(resource, cancellationToken); + return base.PostAsync(resource, cancellationToken); } /// [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, + public override Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { - return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); + return base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } /// [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + public override Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) { - return await base.PatchAsync(id, resource, cancellationToken); + return base.PatchAsync(id, resource, cancellationToken); } /// [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, + public override Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { - return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); + return base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); } /// [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public override Task DeleteAsync(TId id, CancellationToken cancellationToken) { - return await base.DeleteAsync(id, cancellationToken); + return base.DeleteAsync(id, cancellationToken); } /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, + public override Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { - return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); + return base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index 452a5eac09..70d65aa7b3 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -21,8 +21,8 @@ protected JsonApiOperationsController(IJsonApiOptions options, IResourceGraph re /// [HttpPost] - public override async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) + public override Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) { - return await base.PostOperationsAsync(operations, cancellationToken); + return base.PostOperationsAsync(operations, cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index 80d7863251..f88e11c33d 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -16,11 +16,11 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) } /// - public async Task WriteAsync(OutputFormatterWriteContext context) + public Task WriteAsync(OutputFormatterWriteContext context) { ArgumentGuard.NotNull(context); var writer = context.HttpContext.RequestServices.GetRequiredService(); - await writer.WriteAsync(context.Object, context.HttpContext); + return writer.WriteAsync(context.Object, context.HttpContext); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 22de5284a2..8ea0d060e2 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -44,14 +44,14 @@ public JsonApiWriter(IJsonApiRequest request, IJsonApiOptions options, IResponse } /// - public async Task WriteAsync(object? model, HttpContext httpContext) + public Task WriteAsync(object? model, HttpContext httpContext) { ArgumentGuard.NotNull(httpContext); if (model == null && !CanWriteBody((HttpStatusCode)httpContext.Response.StatusCode)) { // Prevent exception from Kestrel server, caused by writing data:null json response. - return; + return Task.CompletedTask; } string? responseBody = GetResponseBody(model, httpContext); @@ -59,7 +59,7 @@ public async Task WriteAsync(object? model, HttpContext httpContext) if (httpContext.Request.Method == HttpMethod.Head.Method) { httpContext.Response.GetTypedHeaders().ContentLength = responseBody == null ? 0 : Encoding.UTF8.GetByteCount(responseBody); - return; + return Task.CompletedTask; } _traceWriter.LogMessage(() => @@ -70,7 +70,7 @@ public async Task WriteAsync(object? model, HttpContext httpContext) return $"Sending {httpContext.Response.StatusCode} response for {method} request at '{url}' with body: <<{responseBody}>>"; }); - await SendResponseBodyAsync(httpContext.Response, responseBody); + return SendResponseBodyAsync(httpContext.Response, responseBody); } private static bool CanWriteBody(HttpStatusCode statusCode) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 5984b6215b..c9a19ff73d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -244,19 +244,19 @@ protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, } } - protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + protected virtual Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) { - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + return _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); } - private async Task AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(TResource primaryResource, CancellationToken cancellationToken) + private Task AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(TResource primaryResource, CancellationToken cancellationToken) { - await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, true, cancellationToken); + return ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, true, cancellationToken); } - protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) + protected Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) { - await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, false, cancellationToken); + return ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, false, cancellationToken); } private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(TResource primaryResource, bool onlyIfTypeHierarchy, diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs index 194d55d837..316acc3381 100644 --- a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -396,9 +396,9 @@ public async Task Can_rollback_on_error() const string personLocalId = "new-person"; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await _testContext.ClearAllTablesAsync(dbContext); + return _testContext.ClearAllTablesAsync(dbContext); }); var requestBody = new diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 517cf4c792..5c9647f6f7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -22,11 +22,11 @@ public CreateMusicTrackOperationsController(IJsonApiOptions options, IResourceGr { } - public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) + public override Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) { AssertOnlyCreatingMusicTracks(operations); - return await base.PostOperationsAsync(operations, cancellationToken); + return base.PostOperationsAsync(operations, cancellationToken); } private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index 4e2fa4f937..e18153fc60 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -42,9 +42,9 @@ public async Task Transforms_on_create_resource_with_side_effects() List newCompanies = _fakers.RecordCompany.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); var requestBody = new diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 82646686d4..54e1ca2b76 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -27,9 +27,9 @@ public async Task Can_rollback_on_error() DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; string newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTablesAsync(); + return dbContext.ClearTablesAsync(); }); string unknownPerformerId = Unknown.StringId.For(); @@ -113,9 +113,9 @@ public async Task Can_restore_to_previous_savepoint_on_error() // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTablesAsync(); + return dbContext.ClearTablesAsync(); }); const string trackLid = "track-1"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index a5ae347886..1ba0e6e746 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -32,9 +32,9 @@ public ResponseMetaTests(IntegrationTestContext, public async Task Returns_top_level_meta() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); const string route = "/supportTickets"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index 955a8514b6..b7d7126d16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -86,9 +86,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_resource_count_for_empty_collection() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); const string route = "/supportTickets"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index d1772330ca..b30c8ef88f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -172,8 +172,8 @@ protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writ protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected virtual Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) { - return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); + return _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs index cb8e84e8ba..74b5190cd1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -118,8 +118,8 @@ protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeO protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected virtual Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) { - return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); + return _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index cc5b5e84ab..38f78abb50 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -19,9 +19,9 @@ public async Task Create_group_writes_to_outbox() string newGroupName = _fakers.DomainGroup.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); var requestBody = new diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index 5ec47bb34a..a00463843a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -20,9 +20,9 @@ public async Task Create_user_writes_to_outbox() string newLoginName = _fakers.DomainUser.Generate().LoginName; string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); var requestBody = new diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 2b6e3be294..6a865eaf85 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -79,16 +79,14 @@ public override async Task AddToToManyRelationshipAsync(TId leftId, string relat await base.AddToToManyRelationshipAsync(leftId, relationshipName, rightResourceIds, cancellationToken); } - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public override Task DeleteAsync(TId id, CancellationToken cancellationToken) { if (IsSoftDeletable(typeof(TResource))) { - await SoftDeleteAsync(id, cancellationToken); - } - else - { - await base.DeleteAsync(id, cancellationToken); + return SoftDeleteAsync(id, cancellationToken); } + + return base.DeleteAsync(id, cancellationToken); } private async Task SoftDeleteAsync(TId id, CancellationToken cancellationToken) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 93881be9eb..665f0bf5f4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -97,9 +97,9 @@ public async Task Can_create_resource_with_empty_ID() // Arrange string newName = _fakers.Map.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); var requestBody = new diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index 19baa0cf58..746ab7d6d2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -96,9 +96,9 @@ public async Task Can_create_resource_with_zero_ID() // Arrange string newTitle = _fakers.Game.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(dbContext => { - await dbContext.ClearTableAsync(); + return dbContext.ClearTableAsync(); }); var requestBody = new diff --git a/test/TestBuildingBlocks/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs index d3a8a0e8ad..f7c8eb7251 100644 --- a/test/TestBuildingBlocks/DbContextExtensions.cs +++ b/test/TestBuildingBlocks/DbContextExtensions.cs @@ -10,17 +10,17 @@ public static void AddInRange(this DbContext dbContext, params object[] entities dbContext.AddRange(entities); } - public static async Task ClearTableAsync(this DbContext dbContext) + public static Task ClearTableAsync(this DbContext dbContext) where TEntity : class { - await ClearTablesAsync(dbContext, typeof(TEntity)); + return ClearTablesAsync(dbContext, typeof(TEntity)); } - public static async Task ClearTablesAsync(this DbContext dbContext) + public static Task ClearTablesAsync(this DbContext dbContext) where TEntity1 : class where TEntity2 : class { - await ClearTablesAsync(dbContext, typeof(TEntity1), typeof(TEntity2)); + return ClearTablesAsync(dbContext, typeof(TEntity1), typeof(TEntity2)); } private static async Task ClearTablesAsync(this DbContext dbContext, params Type[] modelTypes) diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 73ced10d8d..d657d02e23 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -22,40 +22,40 @@ static IntegrationTest() ThrottleSemaphore = new SemaphoreSlim(maxConcurrentTestRuns); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); + return ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); + return ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, object requestBody, string contentType = HeaderConstants.MediaType, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); + return ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, object requestBody, string contentType = HeaderConstants.AtomicOperationsMediaType, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); + return ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, object requestBody, string contentType = HeaderConstants.MediaType, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, setRequestHeaders); + return ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, setRequestHeaders); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, object? requestBody = null, string contentType = HeaderConstants.MediaType, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, setRequestHeaders); + return ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, setRequestHeaders); } private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, @@ -116,9 +116,9 @@ static IntegrationTest() } } - public async Task InitializeAsync() + public Task InitializeAsync() { - await ThrottleSemaphore.WaitAsync(); + return ThrottleSemaphore.WaitAsync(); } public virtual Task DisposeAsync() diff --git a/test/TestBuildingBlocks/QueryableExtensions.cs b/test/TestBuildingBlocks/QueryableExtensions.cs index 71538f13c0..e3f6f33e27 100644 --- a/test/TestBuildingBlocks/QueryableExtensions.cs +++ b/test/TestBuildingBlocks/QueryableExtensions.cs @@ -11,10 +11,10 @@ public static Task FirstWithIdAsync(this IQueryable Equals(resource.Id, id), cancellationToken); } - public static async Task FirstWithIdOrDefaultAsync(this IQueryable resources, TId id, + public static Task FirstWithIdOrDefaultAsync(this IQueryable resources, TId id, CancellationToken cancellationToken = default) where TResource : IIdentifiable { - return await resources.FirstOrDefaultAsync(resource => Equals(resource.Id, id), cancellationToken); + return resources.FirstOrDefaultAsync(resource => Equals(resource.Id, id), cancellationToken); } } From cde7b63738a27f30f5f68d3dc1e5895fbde365d5 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:19:00 +0100 Subject: [PATCH 41/53] Resharper: Use lambda expression --- .../AtomicOperations/AtomicOperationsTests.cs | 5 +---- .../IntegrationTests/Archiving/ArchiveTests.cs | 5 +---- .../Creating/AtomicCreateResourceTests.cs | 5 +---- .../Links/AtomicAbsoluteLinksTests.cs | 5 +---- .../AtomicRelativeLinksWithNamespaceTests.cs | 5 +---- .../Mixed/AtomicTraceLoggingTests.cs | 5 +---- .../AtomicModelStateValidationTests.cs | 5 +---- .../AtomicSerializationResourceDefinitionTests.cs | 5 +---- .../Transactions/AtomicRollbackTests.cs | 10 ++-------- .../IntegrationTests/Blobs/BlobTests.cs | 5 +---- .../ContentNegotiation/AcceptHeaderTests.cs | 5 +---- .../ApiControllerAttributeLogTests.cs | 5 +---- .../ModelState/ModelStateValidationTests.cs | 4 +--- .../InputValidation/RequestBody/WorkflowTests.cs | 5 +---- .../Links/AbsoluteLinksWithNamespaceTests.cs | 5 +---- .../Links/AbsoluteLinksWithoutNamespaceTests.cs | 5 +---- .../Links/RelativeLinksWithNamespaceTests.cs | 5 +---- .../Links/RelativeLinksWithoutNamespaceTests.cs | 5 +---- .../IntegrationTests/Logging/LoggingTests.cs | 5 +---- .../IntegrationTests/Meta/ResponseMetaTests.cs | 10 ++-------- .../IntegrationTests/Meta/TopLevelCountTests.cs | 10 ++-------- .../OutboxTests.Group.cs | 5 +---- .../OutboxTests.User.cs | 5 +---- .../CreateResourceWithClientGeneratedIdTests.cs | 5 +---- .../RemoveFromToManyRelationshipTests.cs | 5 +---- .../Resources/ReplaceToManyRelationshipTests.cs | 5 +---- .../Resources/UpdateToOneRelationshipTests.cs | 5 +---- .../ResourceInjectionTests.cs | 5 +---- .../ResourceInheritanceReadTests.cs | 5 +---- .../IntegrationTests/Serialization/ETagTests.cs | 15 +++------------ .../Serialization/SerializationTests.cs | 5 +---- .../ZeroKeys/EmptyGuidAsKeyTests.cs | 5 +---- .../IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs | 5 +---- test/TestBuildingBlocks/IntegrationTestContext.cs | 11 ++--------- 34 files changed, 40 insertions(+), 160 deletions(-) diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs index 316acc3381..d434d1359c 100644 --- a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -396,10 +396,7 @@ public async Task Can_rollback_on_error() const string personLocalId = "new-person"; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return _testContext.ClearAllTablesAsync(dbContext); - }); + await _testContext.RunOnDatabaseAsync(dbContext => _testContext.ClearAllTablesAsync(dbContext)); var requestBody = new { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 7c282051f0..6b400325e3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -21,10 +21,7 @@ public ArchiveTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 5639d44f40..c77610d8dc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -26,10 +26,7 @@ public AtomicCreateResourceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServices(services => services.AddSingleton()); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = false; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index fdc7369a71..4b1ed95619 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -25,10 +25,7 @@ public AtomicAbsoluteLinksTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index 9184161c07..6d5a276ecb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -26,10 +26,7 @@ public AtomicRelativeLinksWithNamespaceTests( testContext.UseController(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs index d9c315513d..30099131ce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -29,10 +29,7 @@ public AtomicTraceLoggingTests(IntegrationTestContext category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); }); - testContext.ConfigureServices(services => - { - services.AddSingleton(loggerFactory); - }); + testContext.ConfigureServices(services => services.AddSingleton(loggerFactory)); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 98564f3a57..1d095377bd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -17,10 +17,7 @@ public AtomicModelStateValidationTests(IntegrationTestContext - { - services.AddSingleton(); - }); + _testContext.ConfigureServices(services => services.AddSingleton()); testContext.UseController(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index e18153fc60..b8095b38f9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -42,10 +42,7 @@ public async Task Transforms_on_create_resource_with_side_effects() List newCompanies = _fakers.RecordCompany.Generate(2); - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 54e1ca2b76..5fe125928c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -27,10 +27,7 @@ public async Task Can_rollback_on_error() DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; string newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTablesAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTablesAsync()); string unknownPerformerId = Unknown.StringId.For(); @@ -113,10 +110,7 @@ public async Task Can_restore_to_previous_savepoint_on_error() // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTablesAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTablesAsync()); const string trackLid = "track-1"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs index 727245f06e..fca26c66c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs @@ -19,10 +19,7 @@ public BlobTests(IntegrationTestContext, BlobDbCo testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 20fc6f8032..f06df45bb4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -225,10 +225,7 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() const string route = "/operations"; const string contentType = HeaderConstants.AtomicOperationsMediaType; - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - }; + Action setRequestHeaders = headers => headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); // Act (HttpResponseMessage httpResponse, Document responseDocument) = diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs index 949ea5da3b..5a83599a3a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -22,10 +22,7 @@ public ApiControllerAttributeLogTests() options.AddProvider(_loggerFactory); }); - ConfigureServices(services => - { - services.AddSingleton(_loggerFactory); - }); + ConfigureServices(services => services.AddSingleton(_loggerFactory)); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index e6e83fa7c3..21fe575a2b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -23,10 +23,8 @@ public ModelStateValidationTests(IntegrationTestContext - { // Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation. - services.AddDateOnlyTimeOnlyStringConverters(); - }); + services.AddDateOnlyTimeOnlyStringConverters()); #endif } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index 895a8d2dc8..290a168fb1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -17,10 +17,7 @@ public WorkflowTests(IntegrationTestContext, testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index e2449d802b..3221215461 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -25,10 +25,7 @@ public AbsoluteLinksWithNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 48f49abb99..b6060e3d7c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -25,10 +25,7 @@ public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index fb26ca6533..79a2b8408a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -25,10 +25,7 @@ public RelativeLinksWithNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index 4fc2198bca..7e7e1d8f7a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -25,10 +25,7 @@ public RelativeLinksWithoutNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index 8cb79e0376..81aca9c43e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -30,10 +30,7 @@ public LoggingTests(IntegrationTestContext, Lo options.AddFilter((category, _) => category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); }); - testContext.ConfigureServices(services => - { - services.AddSingleton(loggerFactory); - }); + testContext.ConfigureServices(services => services.AddSingleton(loggerFactory)); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index 1ba0e6e746..c96183dca0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -19,10 +19,7 @@ public ResponseMetaTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServices(services => services.AddSingleton()); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = false; @@ -32,10 +29,7 @@ public ResponseMetaTests(IntegrationTestContext, public async Task Returns_top_level_meta() { // Arrange - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); const string route = "/supportTickets"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index b7d7126d16..fbad011db0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -21,10 +21,7 @@ public TopLevelCountTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; @@ -86,10 +83,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_resource_count_for_empty_collection() { // Arrange - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); const string route = "/supportTickets"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index 38f78abb50..505675269f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -19,10 +19,7 @@ public async Task Create_group_writes_to_outbox() string newGroupName = _fakers.DomainGroup.Generate().Name; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index a00463843a..d725731020 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -20,10 +20,7 @@ public async Task Create_user_writes_to_outbox() string newLoginName = _fakers.DomainUser.Generate().LoginName; string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 3a04f5e937..92c88d72a4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -22,10 +22,7 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.ClientIdGeneration = ClientIdGenerationMode.Required; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index c4928b0924..567559b5d4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -25,10 +25,7 @@ public RemoveFromToManyRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddSingleton, RemoveExtraFromWorkItemDefinition>(); - }); + testContext.ConfigureServices(services => services.AddSingleton, RemoveExtraFromWorkItemDefinition>()); var workItemDefinition = (RemoveExtraFromWorkItemDefinition)testContext.Factory.Services.GetRequiredService>(); workItemDefinition.Reset(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index b3007bb936..0cee60ef86 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -22,10 +22,7 @@ public ReplaceToManyRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 636dbe6d3c..e04c33d1eb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -22,10 +22,7 @@ public UpdateToOneRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 7dd8c92726..3f371b2a67 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -21,10 +21,7 @@ public ResourceInjectionTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServices(services => services.AddSingleton()); _fakers = new InjectionFakers(testContext.Factory.Services); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs index 9cea6284b8..2e0417370c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs @@ -35,10 +35,7 @@ protected ResourceInheritanceReadTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.UseRelativeLinks = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs index d9006ae629..4c950ee98e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs @@ -155,10 +155,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/meetings/{existingMeeting.StringId}"; - Action setRequestHeaders = headers => - { - headers.IfMatch.ParseAdd("\"12345\""); - }; + Action setRequestHeaders = headers => headers.IfMatch.ParseAdd("\"12345\""); // Act (HttpResponseMessage httpResponse, Document responseDocument) = @@ -196,10 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string responseETag = httpResponse1.Headers.ETag!.Tag; - Action setRequestHeaders2 = headers => - { - headers.IfNoneMatch.ParseAdd($"\"12345\", W/\"67890\", {responseETag}"); - }; + Action setRequestHeaders2 = headers => headers.IfNoneMatch.ParseAdd($"\"12345\", W/\"67890\", {responseETag}"); // Act (HttpResponseMessage httpResponse2, string responseDocument2) = await _testContext.ExecuteGetAsync(route, setRequestHeaders2); @@ -229,10 +223,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => const string route = "/meetings"; - Action setRequestHeaders = headers => - { - headers.IfNoneMatch.ParseAdd("\"Not-a-matching-value\""); - }; + Action setRequestHeaders = headers => headers.IfNoneMatch.ParseAdd("\"Not-a-matching-value\""); // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index bb2df33c33..00680558a7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -23,10 +23,7 @@ public SerializationTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeExceptionStackTraceInErrors = false; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 665f0bf5f4..fbe71bb1a1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -97,10 +97,7 @@ public async Task Can_create_resource_with_empty_ID() // Arrange string newName = _fakers.Map.Generate().Name; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index 746ab7d6d2..7c5c682e1f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -96,10 +96,7 @@ public async Task Can_create_resource_with_zero_ID() // Arrange string newTitle = _fakers.Game.Generate().Title; - await _testContext.RunOnDatabaseAsync(dbContext => - { - return dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index a7158f9933..dc186b474b 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -170,17 +170,10 @@ protected override IHostBuilder CreateHostBuilder() }) .ConfigureWebHostDefaults(webBuilder => { - webBuilder.ConfigureServices(services => - { - _configureServices?.Invoke(services); - }); - + webBuilder.ConfigureServices(services => _configureServices?.Invoke(services)); webBuilder.UseStartup(); }) - .ConfigureLogging(options => - { - _loggingConfiguration?.Invoke(options); - }); + .ConfigureLogging(options => _loggingConfiguration?.Invoke(options)); // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore From 250cfdf21e208143ce34ec03a1f03b7b4ade819c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 21 Nov 2023 11:22:57 +0100 Subject: [PATCH 42/53] Resharper: use raw strings --- JsonApiDotNetCore.sln.DotSettings | 3 + WarningSeverities.DotSettings | 2 + .../AtomicOperations/AtomicOperationsTests.cs | 170 ++- .../IntegrationTests/DapperTestContext.cs | 5 +- .../QueryStrings/FilterTests.cs | 493 ++++--- .../QueryStrings/IncludeTests.cs | 42 +- .../QueryStrings/SortTests.cs | 152 +- .../QueryStrings/SparseFieldSets.cs | 85 +- .../AddToToManyRelationshipTests.cs | 8 +- .../Relationships/FetchRelationshipTests.cs | 42 +- .../RemoveFromToManyRelationshipTests.cs | 66 +- .../ReplaceToManyRelationshipTests.cs | 107 +- .../UpdateToOneRelationshipTests.cs | 308 +++-- .../Resources/CreateResourceTests.cs | 134 +- .../Resources/DeleteResourceTests.cs | 20 +- .../ReadWrite/Resources/FetchResourceTests.cs | 75 +- .../Resources/UpdateResourceTests.cs | 90 +- .../Sql/SubQueryInJoinTests.cs | 315 +++-- .../IntegrationTests/SqlTextAdapter.cs | 2 +- .../UnitTests/RelationshipForeignKeyTests.cs | 8 +- .../Mixed/AtomicSerializationTests.cs | 118 +- .../Mixed/AtomicTraceLoggingTests.cs | 398 +++--- .../ExceptionHandlerTests.cs | 2 +- .../IntegrationTests/Logging/LoggingTests.cs | 240 ++-- .../Meta/ResponseMetaTests.cs | 36 +- .../ResourceInheritanceReadTests.cs | 1222 +++++++++-------- .../Serialization/SerializationTests.cs | 781 ++++++----- .../Response/ResponseModelAdapterTests.cs | 838 +++++------ .../ControllerGenerationTests.cs | 371 ++--- 29 files changed, 3305 insertions(+), 2828 deletions(-) diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 64125e085d..a945c3a40d 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -66,6 +66,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); WARNING WARNING WARNING + SUGGESTION SUGGESTION SUGGESTION DO_NOT_SHOW @@ -92,6 +93,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); WARNING SUGGESTION SUGGESTION + SUGGESTION WARNING SUGGESTION <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSReformatInactiveBranches>True</CSReformatInactiveBranches></Profile> @@ -114,6 +116,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); True True True + INDENT 1 1 False diff --git a/WarningSeverities.DotSettings b/WarningSeverities.DotSettings index 37457deebc..5c641e606f 100644 --- a/WarningSeverities.DotSettings +++ b/WarningSeverities.DotSettings @@ -124,6 +124,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -260,6 +261,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs index d434d1359c..286820b702 100644 --- a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -214,9 +214,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") -VALUES (@p1, @p2, @p3) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", newOwner.FirstName); @@ -226,9 +228,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newOwnerId); @@ -236,9 +240,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") -VALUES (@p1, @p2, @p3) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", newAssignee.FirstName); @@ -248,9 +254,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[3].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newAssigneeId); @@ -258,9 +266,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[4].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""Tags"" (""Name"", ""TodoItemId"") -VALUES (@p1, @p2) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "Tags" ("Name", "TodoItemId") + VALUES (@p1, @p2) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", newTag.Name); @@ -269,9 +279,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[5].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" -FROM ""Tags"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTagId); @@ -279,10 +291,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[6].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") -VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "TodoItems" ("Description", "Priority", "DurationInHours", "CreatedAt", "LastModifiedAt", "OwnerId", "AssigneeId") + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(7); command.Parameters.Should().Contain("@p1", newTodoItem.Description); @@ -296,10 +309,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[7].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -307,11 +321,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[8].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -319,9 +334,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[9].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", newAssigneeId); @@ -330,11 +347,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[10].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""Name"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -342,9 +360,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[11].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""LastModifiedAt"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "LastModifiedAt" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", DapperTestContext.FrozenTime); @@ -353,9 +373,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[12].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" -SET ""TodoItemId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -364,10 +386,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[13].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -375,8 +398,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[14].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""People"" -WHERE ""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "People" + WHERE "Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newAssigneeId); @@ -463,9 +488,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") -VALUES (@p1, @p2, @p3) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", null); @@ -475,9 +502,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); @@ -485,11 +514,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); @@ -497,9 +527,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[3].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); @@ -508,9 +540,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[4].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", unknownTodoItemId); diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs index 0257490065..6bbcca6777 100644 --- a/test/DapperTests/IntegrationTests/DapperTestContext.cs +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -23,10 +23,11 @@ namespace DapperTests.IntegrationTests; [PublicAPI] public sealed class DapperTestContext : IntegrationTest { - private const string SqlServerClearAllTablesScript = @" + private const string SqlServerClearAllTablesScript = """ EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'; EXEC sp_MSForEachTable 'SET QUOTED_IDENTIFIER ON; DELETE FROM ?'; - EXEC sp_MSForEachTable 'ALTER TABLE ? CHECK CONSTRAINT ALL';"; + EXEC sp_MSForEachTable 'ALTER TABLE ? CHECK CONSTRAINT ALL'; + """; public static readonly DateTimeOffset FrozenTime = 29.September(2018).At(16, 41, 56).AsUtc().ToDateTimeOffset(); diff --git a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs index d23a90765a..56bfdd5a7c 100644 --- a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs +++ b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs @@ -60,10 +60,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""Tags"" AS t1 -LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", 0x00FF00); @@ -71,11 +73,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" -FROM ""Tags"" AS t1 -LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" -WHERE t2.""Id"" = @p1 -ORDER BY t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" = @p1 + ORDER BY t1."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", 0x00FF00); @@ -121,10 +125,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""Tags"" AS t1 -LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" -WHERE t2.""Id"" IN (@p1, @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" IN (@p1, @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", 0x00FF00); @@ -133,11 +139,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" -FROM ""Tags"" AS t1 -LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" -WHERE t2.""Id"" IN (@p1, @p2) -ORDER BY t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" IN (@p1, @p2) + ORDER BY t1."Id" + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", 0x00FF00); @@ -180,11 +188,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -LEFT JOIN ""People"" AS t3 ON t1.""AssigneeId"" = t3.""Id"" -WHERE (t2.""Id"" = @p1) AND (t3.""Id"" IS NULL)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + LEFT JOIN "People" AS t3 ON t1."AssigneeId" = t3."Id" + WHERE (t2."Id" = @p1) AND (t3."Id" IS NULL) + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -192,17 +202,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" - WHERE t3.""Id"" IS NULL -) AS t4 ON t1.""Id"" = t4.""OwnerId"" -WHERE t1.""Id"" = @p1 -ORDER BY t4.""Priority"", t4.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t3 ON t2."AssigneeId" = t3."Id" + WHERE t3."Id" IS NULL + ) AS t4 ON t1."Id" = t4."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY t4."Priority", t4."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -244,10 +255,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE (t2.""Id"" = @p1) AND (t1.""DurationInHours"" IS NULL)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t2."Id" = @p1) AND (t1."DurationInHours" IS NULL) + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -255,16 +268,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - WHERE t2.""DurationInHours"" IS NULL -) AS t3 ON t1.""Id"" = t3.""OwnerId"" -WHERE t1.""Id"" = @p1 -ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."DurationInHours" IS NULL + ) AS t3 ON t1."Id" = t3."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -309,9 +323,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -WHERE t1.""Priority"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE t1."Priority" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItems[1].Priority); @@ -319,11 +335,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Priority"" = @p1 -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Priority" = @p1 + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItems[1].Priority); @@ -368,10 +385,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE (t2.""Id"" = @p1) AND (t1.""Description"" = @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE (t2."Id" = @p1) AND (t1."Description" = @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", person.Id); @@ -380,16 +399,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""AssigneeId"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - WHERE t2.""Description"" = @p2 -) AS t3 ON t1.""Id"" = t3.""AssigneeId"" -WHERE t1.""Id"" = @p1 -ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."AssigneeId", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."Description" = @p2 + ) AS t3 ON t1."Id" = t3."AssigneeId" + WHERE t1."Id" = @p1 + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", person.Id); @@ -435,22 +455,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t2.""LastName"" = t2.""FirstName""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t2."LastName" = t2."FirstName" + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t2.""LastName"" = t2.""FirstName"" -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t2."LastName" = t2."FirstName" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.Should().BeEmpty(); }); @@ -493,10 +516,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE (t2.""Id"" = @p1) AND (t1.""Priority"" = @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t2."Id" = @p1) AND (t1."Priority" = @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", person.Id); @@ -505,16 +530,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - WHERE t2.""Priority"" = @p2 -) AS t3 ON t1.""Id"" = t3.""OwnerId"" -WHERE t1.""Id"" = @p1 -ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."Priority" = @p2 + ) AS t3 ON t1."Id" = t3."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", person.Id); @@ -558,9 +584,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -WHERE t1.""Description"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE t1."Description" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", "X"); @@ -568,11 +596,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Description"" = @p1 -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Description" = @p1 + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", "X"); @@ -617,9 +646,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1 -WHERE (NOT (t1.""FirstName"" = @p1)) OR (t1.""FirstName"" IS NULL)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + WHERE (NOT (t1."FirstName" = @p1)) OR (t1."FirstName" IS NULL) + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", "X"); @@ -627,10 +658,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE (NOT (t1.""FirstName"" = @p1)) OR (t1.""FirstName"" IS NULL) -ORDER BY t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE (NOT (t1."FirstName" = @p1)) OR (t1."FirstName" IS NULL) + ORDER BY t1."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", "X"); @@ -676,10 +709,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE (NOT ((t2.""FirstName"" = @p1) AND (t2.""LastName"" = @p2))) OR (t2.""FirstName"" IS NULL) OR (t2.""LastName"" IS NULL)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE (NOT ((t2."FirstName" = @p1) AND (t2."LastName" = @p2))) OR (t2."FirstName" IS NULL) OR (t2."LastName" IS NULL) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", "X"); @@ -688,12 +723,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE (NOT ((t2.""FirstName"" = @p1) AND (t2.""LastName"" = @p2))) OR (t2.""FirstName"" IS NULL) OR (t2.""LastName"" IS NULL) -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE (NOT ((t2."FirstName" = @p1) AND (t2."LastName" = @p2))) OR (t2."FirstName" IS NULL) OR (t2."LastName" IS NULL) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", "X"); @@ -742,10 +778,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE (t1.""Description"" LIKE 'T%') AND (NOT (t1.""Description"" IN (@p1, @p2))) AND (t2.""FirstName"" = @p3) AND (t1.""Description"" LIKE '%o%')")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t1."Description" LIKE 'T%') AND (NOT (t1."Description" IN (@p1, @p2))) AND (t2."FirstName" = @p3) AND (t1."Description" LIKE '%o%') + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", "Four"); @@ -755,12 +793,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE (t1.""Description"" LIKE 'T%') AND (NOT (t1.""Description"" IN (@p1, @p2))) AND (t2.""FirstName"" = @p3) AND (t1.""Description"" LIKE '%o%') -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t1."Description" LIKE 'T%') AND (NOT (t1."Description" IN (@p1, @p2))) AND (t2."FirstName" = @p3) AND (t1."Description" LIKE '%o%') + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", "Four"); @@ -813,19 +852,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""Tags"" AS t1 -WHERE (t1.""Name"" LIKE '%A\%%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\_%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\\%' ESCAPE '\') OR (t1.""Name"" LIKE '%A''%') OR (t1.""Name"" LIKE '%\%\_\\''%' ESCAPE '\')")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + WHERE (t1."Name" LIKE '%A\%%' ESCAPE '\') OR (t1."Name" LIKE '%A\_%' ESCAPE '\') OR (t1."Name" LIKE '%A\\%' ESCAPE '\') OR (t1."Name" LIKE '%A''%') OR (t1."Name" LIKE '%\%\_\\''%' ESCAPE '\') + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" -FROM ""Tags"" AS t1 -WHERE (t1.""Name"" LIKE '%A\%%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\_%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\\%' ESCAPE '\') OR (t1.""Name"" LIKE '%A''%') OR (t1.""Name"" LIKE '%\%\_\\''%' ESCAPE '\') -ORDER BY t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE (t1."Name" LIKE '%A\%%' ESCAPE '\') OR (t1."Name" LIKE '%A\_%' ESCAPE '\') OR (t1."Name" LIKE '%A\\%' ESCAPE '\') OR (t1."Name" LIKE '%A''%') OR (t1."Name" LIKE '%\%\_\\''%' ESCAPE '\') + ORDER BY t1."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -871,9 +914,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -WHERE (t1.""DurationInHours"" > @p1) OR (t1.""DurationInHours"" <= @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE (t1."DurationInHours" > @p1) OR (t1."DurationInHours" <= @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", 250); @@ -882,11 +927,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE (t1.""DurationInHours"" > @p1) OR (t1.""DurationInHours"" <= @p2) -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE (t1."DurationInHours" > @p1) OR (t1."DurationInHours" <= @p2) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", 250); @@ -932,15 +978,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t4 ON t1.""OwnerId"" = t4.""Id"" -WHERE (( - SELECT COUNT(*) - FROM ""People"" AS t2 - LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" - WHERE t1.""OwnerId"" = t2.""Id"" -) > @p1) AND (NOT (t4.""Id"" IS NULL))")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t4 ON t1."OwnerId" = t4."Id" + WHERE (( + SELECT COUNT(*) + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + WHERE t1."OwnerId" = t2."Id" + ) > @p1) AND (NOT (t4."Id" IS NULL)) + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", 1); @@ -948,17 +996,18 @@ SELECT COUNT(*) store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t4 ON t1.""OwnerId"" = t4.""Id"" -WHERE (( - SELECT COUNT(*) - FROM ""People"" AS t2 - LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" - WHERE t1.""OwnerId"" = t2.""Id"" -) > @p1) AND (NOT (t4.""Id"" IS NULL)) -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t4 ON t1."OwnerId" = t4."Id" + WHERE (( + SELECT COUNT(*) + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + WHERE t1."OwnerId" = t2."Id" + ) > @p1) AND (NOT (t4."Id" IS NULL)) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", 1); @@ -1013,19 +1062,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -WHERE EXISTS ( - SELECT 1 - FROM ""People"" AS t2 - LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" - INNER JOIN ""People"" AS t5 ON t3.""OwnerId"" = t5.""Id"" - WHERE (t1.""OwnerId"" = t2.""Id"") AND (EXISTS ( - SELECT 1 - FROM ""Tags"" AS t4 - WHERE (t3.""Id"" = t4.""TodoItemId"") AND (t4.""Name"" = @p1) - )) AND (t5.""LastName"" = @p2) AND (t3.""Description"" = @p3) -)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + INNER JOIN "People" AS t5 ON t3."OwnerId" = t5."Id" + WHERE (t1."OwnerId" = t2."Id") AND (EXISTS ( + SELECT 1 + FROM "Tags" AS t4 + WHERE (t3."Id" = t4."TodoItemId") AND (t4."Name" = @p1) + )) AND (t5."LastName" = @p2) AND (t3."Description" = @p3) + ) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", "Personal"); @@ -1035,21 +1086,22 @@ SELECT 1 store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE EXISTS ( - SELECT 1 - FROM ""People"" AS t2 - LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" - INNER JOIN ""People"" AS t5 ON t3.""OwnerId"" = t5.""Id"" - WHERE (t1.""OwnerId"" = t2.""Id"") AND (EXISTS ( - SELECT 1 - FROM ""Tags"" AS t4 - WHERE (t3.""Id"" = t4.""TodoItemId"") AND (t4.""Name"" = @p1) - )) AND (t5.""LastName"" = @p2) AND (t3.""Description"" = @p3) -) -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + INNER JOIN "People" AS t5 ON t3."OwnerId" = t5."Id" + WHERE (t1."OwnerId" = t2."Id") AND (EXISTS ( + SELECT 1 + FROM "Tags" AS t4 + WHERE (t3."Id" = t4."TodoItemId") AND (t4."Name" = @p1) + )) AND (t5."LastName" = @p2) AND (t3."Description" = @p3) + ) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", "Personal"); @@ -1100,29 +1152,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1 -WHERE EXISTS ( - SELECT 1 - FROM ""TodoItems"" AS t2 - LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" - WHERE (t1.""Id"" = t2.""OwnerId"") AND (NOT (t3.""Id"" IS NULL)) AND (t3.""FirstName"" IS NULL) -)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t3 ON t2."AssigneeId" = t3."Id" + WHERE (t1."Id" = t2."OwnerId") AND (NOT (t3."Id" IS NULL)) AND (t3."FirstName" IS NULL) + ) + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE EXISTS ( - SELECT 1 - FROM ""TodoItems"" AS t2 - LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" - WHERE (t1.""Id"" = t2.""OwnerId"") AND (NOT (t3.""Id"" IS NULL)) AND (t3.""FirstName"" IS NULL) -) -ORDER BY t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t3 ON t2."AssigneeId" = t3."Id" + WHERE (t1."Id" = t2."OwnerId") AND (NOT (t3."Id" IS NULL)) AND (t3."FirstName" IS NULL) + ) + ORDER BY t1."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -1185,9 +1241,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -WHERE (t1.""Description"" = @p1) AND ((t1.""Priority"" = @p2) OR (t1.""DurationInHours"" = @p3))")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE (t1."Description" = @p1) AND ((t1."Priority" = @p2) OR (t1."DurationInHours" = @p3)) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", "1"); @@ -1197,11 +1255,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE (t1.""Description"" = @p1) AND ((t1.""Priority"" = @p2) OR (t1.""DurationInHours"" = @p3)) -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE (t1."Description" = @p1) AND ((t1."Priority" = @p2) OR (t1."DurationInHours" = @p3)) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", "1"); diff --git a/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs index 153e028b01..09d307a256 100644 --- a/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs +++ b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs @@ -148,22 +148,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""FirstName"", t3.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"", t5.""Id"", t5.""Name"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" -LEFT JOIN ""TodoItems"" AS t4 ON t3.""Id"" = t4.""AssigneeId"" -LEFT JOIN ""Tags"" AS t5 ON t1.""Id"" = t5.""TodoItemId"" -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, t4.""Priority"", t4.""LastModifiedAt"" DESC, t5.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName", t3."Id", t3."FirstName", t3."LastName", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority", t5."Id", t5."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + INNER JOIN "People" AS t3 ON t1."OwnerId" = t3."Id" + LEFT JOIN "TodoItems" AS t4 ON t3."Id" = t4."AssigneeId" + LEFT JOIN "Tags" AS t5 ON t1."Id" = t5."TodoItemId" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC, t4."Priority", t4."LastModifiedAt" DESC, t5."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -213,20 +216,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""Name"", t3.""Id"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" -LEFT JOIN ""RgbColors"" AS t3 ON t2.""Id"" = t3.""TagId"" -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."Name", t3."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + LEFT JOIN "RgbColors" AS t3 ON t2."Id" = t3."TagId" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC, t2."Id" + """)); command.Parameters.Should().BeEmpty(); }); diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs index bdde400b84..33643519ce 100644 --- a/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs +++ b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs @@ -60,18 +60,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -ORDER BY t1.""Description"" DESC, t1.""DurationInHours"", t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + ORDER BY t1."Description" DESC, t1."DurationInHours", t1."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -125,10 +128,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -136,13 +141,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t3.""Id"", t3.""Name"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -LEFT JOIN ""Tags"" AS t3 ON t2.""Id"" = t3.""TodoItemId"" -WHERE t1.""Id"" = @p1 -ORDER BY t2.""DurationInHours"" DESC, t3.""Name""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority", t3."Id", t3."Name" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + LEFT JOIN "Tags" AS t3 ON t2."Id" = t3."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY t2."DurationInHours" DESC, t3."Name" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -188,22 +194,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -ORDER BY ( - SELECT COUNT(*) - FROM ""Tags"" AS t2 - WHERE t1.""Id"" = t2.""TodoItemId"" -) DESC, t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t2 + WHERE t1."Id" = t2."TodoItemId" + ) DESC, t1."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -248,10 +257,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -259,16 +270,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -WHERE t1.""Id"" = @p1 -ORDER BY ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" -) DESC, t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) DESC, t2."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -314,10 +326,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -325,17 +339,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t4.""Id"", t4.""Name"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" -WHERE t1.""Id"" = @p1 -ORDER BY ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" -) DESC, t2.""Id"", t4.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority", t4."Id", t4."Name" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + LEFT JOIN "Tags" AS t4 ON t2."Id" = t4."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) DESC, t2."Id", t4."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -386,23 +401,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -ORDER BY t1.""Id"", ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" -) DESC, t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY t1."Id", ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) DESC, t2."Id" + """)); command.Parameters.Should().BeEmpty(); }); diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs index 719b3b2d36..e66616810e 100644 --- a/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs +++ b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs @@ -88,20 +88,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""Description"", t1.""DurationInHours"", t2.""Id"", t2.""LastName"", t3.""Id"", t3.""LastName"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Description", t1."DurationInHours", t2."Id", t2."LastName", t3."Id", t3."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + INNER JOIN "People" AS t3 ON t1."OwnerId" = t3."Id" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.Should().BeEmpty(); }); @@ -142,9 +145,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Description"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Description" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -192,10 +197,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""Tags"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."TodoItemId" = t2."Id" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -203,11 +210,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" -WHERE t1.""Id"" = @p1 -ORDER BY t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY t2."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -247,9 +256,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -289,9 +300,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -332,9 +345,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", person.Id); @@ -380,11 +395,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Description"", t2.""Id"", t2.""Name"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" -WHERE t1.""Id"" = @p1 -ORDER BY t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Description", t2."Id", t2."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY t2."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs index 80206b0750..e6c49c1f62 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs @@ -80,9 +80,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""OwnerId"" = @p1 -WHERE ""Id"" IN (@p2, @p3)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "OwnerId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", existingPerson.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs index 22ebd01a4e..227bbdb755 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs @@ -56,10 +56,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -99,10 +101,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -146,10 +150,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""Tags"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."TodoItemId" = t2."Id" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -157,11 +163,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" -WHERE t1.""Id"" = @p1 -ORDER BY t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY t2."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs index c118d8ff59..f31a89c73e 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs @@ -82,14 +82,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t3.""Id"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""AssigneeId"" - FROM ""TodoItems"" AS t2 - WHERE t2.""Id"" IN (@p2, @p3) -) AS t3 ON t1.""Id"" = t3.""AssigneeId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."AssigneeId" + FROM "TodoItems" AS t2 + WHERE t2."Id" IN (@p2, @p3) + ) AS t3 ON t1."Id" = t3."AssigneeId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -99,9 +101,11 @@ LEFT JOIN ( store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" IN (@p1, @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" IN (@p1, @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.AssignedTodoItems.ElementAt(0).Id); @@ -110,9 +114,11 @@ LEFT JOIN ( store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" IN (@p2, @p3)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", null); @@ -181,14 +187,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t3.""Id"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""OwnerId"" - FROM ""TodoItems"" AS t2 - WHERE t2.""Id"" IN (@p2, @p3) -) AS t3 ON t1.""Id"" = t3.""OwnerId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."OwnerId" + FROM "TodoItems" AS t2 + WHERE t2."Id" IN (@p2, @p3) + ) AS t3 ON t1."Id" = t3."OwnerId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -198,9 +206,11 @@ LEFT JOIN ( store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" IN (@p1, @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" IN (@p1, @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); @@ -209,8 +219,10 @@ LEFT JOIN ( store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" -WHERE ""Id"" IN (@p1, @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" IN (@p1, @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs index e1bbcf1190..05b0710132 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs @@ -63,11 +63,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -75,9 +76,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" IN (@p2, @p3)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", null); @@ -128,11 +131,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -140,8 +144,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" -WHERE ""Id"" IN (@p1, @p2)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" IN (@p1, @p2) + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); @@ -207,11 +213,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -219,9 +226,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" IN (@p2, @p3)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -283,11 +292,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -295,9 +305,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -306,9 +318,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -369,11 +383,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -381,8 +396,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" -WHERE ""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); @@ -390,9 +407,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""OwnerId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "OwnerId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs index 5ed90caa2a..e42efaf2e1 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs @@ -69,11 +69,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" -FROM ""People"" AS t1 -LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -81,9 +82,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -138,11 +141,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""LoginAccounts"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); @@ -150,9 +154,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -194,11 +200,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""LoginAccounts"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); @@ -245,11 +252,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""EmailAddress"", t2.""PhoneNumber"" -FROM ""LoginAccounts"" AS t1 -INNER JOIN ""AccountRecoveries"" AS t2 ON t1.""RecoveryId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."EmailAddress", t2."PhoneNumber" + FROM "LoginAccounts" AS t1 + INNER JOIN "AccountRecoveries" AS t2 ON t1."RecoveryId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); @@ -296,11 +304,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""EmailAddress"", t1.""PhoneNumber"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" -FROM ""AccountRecoveries"" AS t1 -LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""Id"" = t2.""RecoveryId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."EmailAddress", t1."PhoneNumber", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "AccountRecoveries" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."Id" = t2."RecoveryId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); @@ -349,11 +358,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -361,9 +371,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -411,11 +423,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -470,11 +483,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" -FROM ""People"" AS t1 -LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -482,9 +496,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""AccountId"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "AccountId" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -493,9 +509,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); @@ -552,11 +570,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""LoginAccounts"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); @@ -564,9 +583,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); @@ -622,11 +643,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -634,9 +656,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson.Id); @@ -699,11 +723,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" -FROM ""People"" AS t1 -LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingPerson1.Id); @@ -711,9 +736,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""AccountId"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "AccountId" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -722,9 +749,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingPerson2.Account.Id); @@ -789,11 +818,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""LoginAccounts"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); @@ -801,9 +831,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", null); @@ -812,9 +844,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); @@ -877,11 +911,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""EmailAddress"", t2.""PhoneNumber"" -FROM ""LoginAccounts"" AS t1 -INNER JOIN ""AccountRecoveries"" AS t2 ON t1.""RecoveryId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."EmailAddress", t2."PhoneNumber" + FROM "LoginAccounts" AS t1 + INNER JOIN "AccountRecoveries" AS t2 ON t1."RecoveryId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); @@ -889,8 +924,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" -WHERE ""RecoveryId"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "LoginAccounts" + WHERE "RecoveryId" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); @@ -898,9 +935,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""LoginAccounts"" -SET ""RecoveryId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "LoginAccounts" + SET "RecoveryId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); @@ -963,11 +1002,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""EmailAddress"", t1.""PhoneNumber"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" -FROM ""AccountRecoveries"" AS t1 -LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""Id"" = t2.""RecoveryId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."EmailAddress", t1."PhoneNumber", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "AccountRecoveries" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."Id" = t2."RecoveryId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); @@ -975,8 +1015,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" -WHERE ""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "LoginAccounts" + WHERE "Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Account.Id); @@ -984,9 +1026,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""LoginAccounts"" -SET ""RecoveryId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "LoginAccounts" + SET "RecoveryId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); @@ -1045,11 +1089,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); @@ -1057,9 +1102,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""AssigneeId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingTodoItem2.Assignee.Id); @@ -1116,11 +1163,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); @@ -1128,9 +1176,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""OwnerId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "OwnerId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingTodoItem2.Owner.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs index 88ee185132..199c8641e6 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs @@ -137,10 +137,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") -VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "TodoItems" ("Description", "Priority", "DurationInHours", "CreatedAt", "LastModifiedAt", "OwnerId", "AssigneeId") + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(7); command.Parameters.Should().Contain("@p1", newTodoItem.Description); @@ -154,9 +155,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" -SET ""TodoItemId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -165,10 +168,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -265,10 +269,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") -VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "TodoItems" ("Description", "Priority", "DurationInHours", "CreatedAt", "LastModifiedAt", "OwnerId", "AssigneeId") + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(7); command.Parameters.Should().Contain("@p1", newTodoItem.Description); @@ -282,10 +287,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newTodoItemId); @@ -435,8 +441,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" -WHERE ""RecoveryId"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "LoginAccounts" + WHERE "RecoveryId" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); @@ -444,9 +452,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""LoginAccounts"" (""UserName"", ""LastUsedAt"", ""RecoveryId"") -VALUES (@p1, @p2, @p3) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "LoginAccounts" ("UserName", "LastUsedAt", "RecoveryId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", newUserName); @@ -456,9 +466,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" -SET ""AccountId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", newLoginAccountId); @@ -467,9 +479,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[3].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"" -FROM ""LoginAccounts"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName" + FROM "LoginAccounts" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newLoginAccountId); @@ -528,9 +542,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") -VALUES (@p1, @p2, @p3) -RETURNING ""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", newPerson.FirstName); @@ -540,9 +556,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" -FROM ""People"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newPersonId); @@ -613,8 +631,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""RgbColors"" -WHERE ""TagId"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "RgbColors" + WHERE "TagId" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTag.Id); @@ -622,9 +642,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""RgbColors"" (""Id"", ""TagId"") -VALUES (@p1, @p2) -RETURNING ""Id""", true)); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "RgbColors" ("Id", "TagId") + VALUES (@p1, @p2) + RETURNING "Id" + """, true)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", newColor.Id); @@ -633,9 +655,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""RgbColors"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "RgbColors" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", newColor.Id); @@ -701,8 +725,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""RgbColors"" -WHERE ""TagId"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "RgbColors" + WHERE "TagId" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTag.Id); @@ -710,9 +736,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""RgbColors"" (""Id"", ""TagId"") -VALUES (@p1, @p2) -RETURNING ""Id""", true)); + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "RgbColors" ("Id", "TagId") + VALUES (@p1, @p2) + RETURNING "Id" + """, true)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingColor.Id); @@ -721,9 +749,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""RgbColors"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "RgbColors" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingColor.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs index 94f6d17b60..af81d138a6 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs @@ -66,8 +66,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" -WHERE ""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -103,8 +105,10 @@ public async Task Cannot_delete_unknown_resource() store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" -WHERE ""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", unknownTodoItemId); @@ -112,9 +116,11 @@ public async Task Cannot_delete_unknown_resource() store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", unknownTodoItemId); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs index 327a0b3051..0027de1d38 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs @@ -74,18 +74,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); command.Parameters.Should().BeEmpty(); }); @@ -131,10 +134,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -170,10 +174,11 @@ public async Task Cannot_get_unknown_primary_resource_by_ID() store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", unknownTodoItemId); @@ -223,10 +228,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""Tags"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" -WHERE t2.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."TodoItemId" = t2."Id" + WHERE t2."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -234,11 +241,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""Name"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" -WHERE t1.""Id"" = @p1 -ORDER BY t2.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY t2."Id" + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -283,10 +292,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); @@ -325,10 +336,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""FirstName"", t2.""LastName"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", todoItem.Id); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs index 7ac11df3c9..f51b7c6b52 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs @@ -77,9 +77,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" -FROM ""Tags"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTag.Id); @@ -87,9 +89,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" -FROM ""Tags"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTag.Id); @@ -181,10 +185,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -192,9 +197,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""Description"" = @p1, ""DurationInHours"" = @p2, ""LastModifiedAt"" = @p3 -WHERE ""Id"" = @p4")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "Description" = @p1, "DurationInHours" = @p2, "LastModifiedAt" = @p3 + WHERE "Id" = @p4 + """)); command.Parameters.ShouldHaveCount(4); command.Parameters.Should().Contain("@p1", newDescription); @@ -205,10 +212,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -334,13 +342,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""FirstName"", t3.""LastName"", t4.""Id"", t4.""Name"" -FROM ""TodoItems"" AS t1 -LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" -INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" -LEFT JOIN ""Tags"" AS t4 ON t1.""Id"" = t4.""TodoItemId"" -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName", t3."Id", t3."FirstName", t3."LastName", t4."Id", t4."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + INNER JOIN "People" AS t3 ON t1."OwnerId" = t3."Id" + LEFT JOIN "Tags" AS t4 ON t1."Id" = t4."TodoItemId" + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -348,9 +357,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" -SET ""Description"" = @p1, ""Priority"" = @p2, ""DurationInHours"" = @p3, ""LastModifiedAt"" = @p4, ""OwnerId"" = @p5, ""AssigneeId"" = @p6 -WHERE ""Id"" = @p7")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "Description" = @p1, "Priority" = @p2, "DurationInHours" = @p3, "LastModifiedAt" = @p4, "OwnerId" = @p5, "AssigneeId" = @p6 + WHERE "Id" = @p7 + """)); command.Parameters.ShouldHaveCount(7); command.Parameters.Should().Contain("@p1", newTodoItem.Description); @@ -364,9 +375,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[2].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" -SET ""TodoItemId"" = @p1 -WHERE ""Id"" IN (@p2, @p3)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); command.Parameters.ShouldHaveCount(3); command.Parameters.Should().Contain("@p1", null); @@ -376,9 +389,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[3].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" -SET ""TodoItemId"" = @p1 -WHERE ""Id"" = @p2")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" = @p2 + """)); command.Parameters.ShouldHaveCount(2); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); @@ -387,10 +402,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[4].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" -FROM ""TodoItems"" AS t1 -WHERE t1.""Id"" = @p1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); command.Parameters.ShouldHaveCount(1); command.Parameters.Should().Contain("@p1", existingTodoItem.Id); diff --git a/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs index 235ec91f2a..ed75d24d65 100644 --- a/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs +++ b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs @@ -48,19 +48,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" -FROM ""People"" AS t1 -LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" -ORDER BY t1.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + ORDER BY t1."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -94,19 +97,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -ORDER BY t1.""Id"", t2.""Priority"", t2.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY t1."Id", t2."Priority", t2."LastModifiedAt" DESC + """)); command.Parameters.Should().BeEmpty(); }); @@ -140,19 +146,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -ORDER BY t1.""Id"", t2.""Description""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY t1."Id", t2."Description" + """)); command.Parameters.Should().BeEmpty(); }); @@ -186,23 +195,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -ORDER BY t1.""Id"", ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" -)")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY t1."Id", ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) + """)); command.Parameters.Should().BeEmpty(); }); @@ -236,24 +248,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t4.""Id"", t4.""Name"" -FROM ""People"" AS t1 -LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" -LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" -ORDER BY t1.""Id"", ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" -), t4.""Name"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority", t4."Id", t4."Name" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + LEFT JOIN "Tags" AS t4 ON t2."Id" = t4."TodoItemId" + ORDER BY t1."Id", ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ), t4."Name" DESC + """)); command.Parameters.Should().BeEmpty(); }); @@ -289,31 +304,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""TodoItems"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"", t5.""Id"", t5.""Name"", t6.""Id"", t6.""CreatedAt"", t6.""Description"", t6.""DurationInHours"", t6.""LastModifiedAt"", t6.""Priority"", t8.""Id"", t8.""Name"" -FROM ""TodoItems"" AS t1 -INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" -LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" -LEFT JOIN ""Tags"" AS t5 ON t3.""Id"" = t5.""TodoItemId"" -LEFT JOIN ""TodoItems"" AS t6 ON t2.""Id"" = t6.""OwnerId"" -LEFT JOIN ""Tags"" AS t8 ON t6.""Id"" = t8.""TodoItemId"" -ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, ( - SELECT COUNT(*) - FROM ""Tags"" AS t4 - WHERE t3.""Id"" = t4.""TodoItemId"" -), t5.""Id"", ( - SELECT COUNT(*) - FROM ""Tags"" AS t7 - WHERE t6.""Id"" = t7.""TodoItemId"" -), t8.""Id""")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority", t5."Id", t5."Name", t6."Id", t6."CreatedAt", t6."Description", t6."DurationInHours", t6."LastModifiedAt", t6."Priority", t8."Id", t8."Name" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + LEFT JOIN "Tags" AS t5 ON t3."Id" = t5."TodoItemId" + LEFT JOIN "TodoItems" AS t6 ON t2."Id" = t6."OwnerId" + LEFT JOIN "Tags" AS t8 ON t6."Id" = t8."TodoItemId" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC, ( + SELECT COUNT(*) + FROM "Tags" AS t4 + WHERE t3."Id" = t4."TodoItemId" + ), t5."Id", ( + SELECT COUNT(*) + FROM "Tags" AS t7 + WHERE t6."Id" = t7."TodoItemId" + ), t8."Id" + """)); command.Parameters.Should().BeEmpty(); }); @@ -347,23 +365,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - WHERE t2.""Description"" = @p1 -) AS t3 ON t1.""Id"" = t3.""OwnerId"" -ORDER BY t1.""Id"", t3.""Priority"", t3.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."Description" = @p1 + ) AS t3 ON t1."Id" = t3."OwnerId" + ORDER BY t1."Id", t3."Priority", t3."LastModifiedAt" DESC + """)); command.Parameters.Should().HaveCount(1); command.Parameters.Should().Contain("@p1", "X"); @@ -398,27 +419,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - WHERE EXISTS ( - SELECT 1 - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" - ) -) AS t4 ON t1.""Id"" = t4.""OwnerId"" -ORDER BY t1.""Id"", t4.""Priority"", t4.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE EXISTS ( + SELECT 1 + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) + ) AS t4 ON t1."Id" = t4."OwnerId" + ORDER BY t1."Id", t4."Priority", t4."LastModifiedAt" DESC + """)); command.Parameters.Should().BeEmpty(); }); @@ -452,27 +476,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" - FROM ""TodoItems"" AS t2 - WHERE ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t2.""Id"" = t3.""TodoItemId"" - ) > @p1 -) AS t4 ON t1.""Id"" = t4.""OwnerId"" -ORDER BY t1.""Id"", t4.""Priority"", t4.""LastModifiedAt"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) > @p1 + ) AS t4 ON t1."Id" = t4."OwnerId" + ORDER BY t1."Id", t4."Priority", t4."LastModifiedAt" DESC + """)); command.Parameters.Should().HaveCount(1); command.Parameters.Should().Contain("@p1", 0); @@ -508,28 +535,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t5.""Id"", t5.""CreatedAt"", t5.""Description"", t5.""DurationInHours"", t5.""LastModifiedAt"", t5.""Priority"", t5.Id0 AS Id, t5.""Name"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"", t4.""Id"" AS Id0, t4.""Name"" - FROM ""TodoItems"" AS t2 - LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" - WHERE t2.""Description"" = @p1 -) AS t5 ON t1.""Id"" = t5.""OwnerId"" -ORDER BY t1.""Id"", ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t5.""Id"" = t3.""TodoItemId"" -), t5.""Name"" DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t5."Id", t5."CreatedAt", t5."Description", t5."DurationInHours", t5."LastModifiedAt", t5."Priority", t5.Id0 AS Id, t5."Name" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority", t4."Id" AS Id0, t4."Name" + FROM "TodoItems" AS t2 + LEFT JOIN "Tags" AS t4 ON t2."Id" = t4."TodoItemId" + WHERE t2."Description" = @p1 + ) AS t5 ON t1."Id" = t5."OwnerId" + ORDER BY t1."Id", ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t5."Id" = t3."TodoItemId" + ), t5."Name" DESC + """)); command.Parameters.Should().HaveCount(1); command.Parameters.Should().Contain("@p1", "X"); @@ -566,33 +596,36 @@ await _testContext.RunOnDatabaseAsync(async dbContext => store.SqlCommands[0].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) -FROM ""People"" AS t1")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); command.Parameters.Should().BeEmpty(); }); store.SqlCommands[1].With(command => { - command.Statement.Should().Be(_testContext.AdaptSql( - @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t7.""Id"", t7.""CreatedAt"", t7.""Description"", t7.""DurationInHours"", t7.""LastModifiedAt"", t7.""Priority"", t7.Id00 AS Id, t7.""Name"" -FROM ""People"" AS t1 -LEFT JOIN ( - SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"", t4.""LastName"", t6.""Id"" AS Id00, t6.""Name"" - FROM ""TodoItems"" AS t2 - LEFT JOIN ""People"" AS t4 ON t2.""AssigneeId"" = t4.""Id"" - LEFT JOIN ( - SELECT t5.""Id"", t5.""Name"", t5.""TodoItemId"" - FROM ""Tags"" AS t5 - WHERE NOT (t5.""Name"" = @p2) - ) AS t6 ON t2.""Id"" = t6.""TodoItemId"" - WHERE NOT (t2.""Description"" = @p1) -) AS t7 ON t1.""Id"" = t7.""OwnerId"" -ORDER BY t1.""Id"", ( - SELECT COUNT(*) - FROM ""Tags"" AS t3 - WHERE t7.""Id"" = t3.""TodoItemId"" -), t7.""LastName"", t7.""Name"", t7.Id00 DESC")); + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t7."Id", t7."CreatedAt", t7."Description", t7."DurationInHours", t7."LastModifiedAt", t7."Priority", t7.Id00 AS Id, t7."Name" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority", t4."LastName", t6."Id" AS Id00, t6."Name" + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t4 ON t2."AssigneeId" = t4."Id" + LEFT JOIN ( + SELECT t5."Id", t5."Name", t5."TodoItemId" + FROM "Tags" AS t5 + WHERE NOT (t5."Name" = @p2) + ) AS t6 ON t2."Id" = t6."TodoItemId" + WHERE NOT (t2."Description" = @p1) + ) AS t7 ON t1."Id" = t7."OwnerId" + ORDER BY t1."Id", ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t7."Id" = t3."TodoItemId" + ), t7."LastName", t7."Name", t7.Id00 DESC + """)); command.Parameters.Should().HaveCount(2); command.Parameters.Should().Contain("@p1", "X"); diff --git a/test/DapperTests/IntegrationTests/SqlTextAdapter.cs b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs index 14fe65bd22..a88646c7a7 100644 --- a/test/DapperTests/IntegrationTests/SqlTextAdapter.cs +++ b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs @@ -7,7 +7,7 @@ internal sealed class SqlTextAdapter { private static readonly Dictionary SqlServerReplacements = new() { - [new Regex(@"""([^""]+)""", RegexOptions.Compiled)] = "[$+]", + [new Regex("\"([^\"]+)\"", RegexOptions.Compiled)] = "[$+]", [new Regex($@"(VALUES \([^)]*\)){Environment.NewLine}RETURNING \[Id\]", RegexOptions.Compiled)] = $"OUTPUT INSERTED.[Id]{Environment.NewLine}$1" }; diff --git a/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs index aadedc8c85..fe58b3b183 100644 --- a/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs +++ b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs @@ -25,7 +25,9 @@ public void Can_format_foreign_key_for_ToOne_relationship() var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, parentRelationship, true, "ParentId", true); // Assert - foreignKey.ToString().Should().Be(@"TestResource.Parent => ""TestResources"".""ParentId""?"); + foreignKey.ToString().Should().Be(""" + TestResource.Parent => "TestResources"."ParentId"? + """); } [Fact] @@ -39,7 +41,9 @@ public void Can_format_foreign_key_for_ToMany_relationship() var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, childrenRelationship, false, "TestResourceId", false); // Assert - foreignKey.ToString().Should().Be(@"TestResource.Children => ""TestResources"".""TestResourceId"""); + foreignKey.ToString().Should().Be(""" + TestResource.Children => "TestResources"."TestResourceId" + """); } [UsedImplicitly] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index b59100dbd9..4f6a2f0a3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -89,42 +89,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"", - ""ext"": [ - ""https://jsonapi.org/ext/atomic"" - ] - }, - ""links"": { - ""self"": ""http://localhost/operations"" - }, - ""atomic:results"": [ - { - ""data"": null - }, - { - ""data"": { - ""type"": ""textLanguages"", - ""id"": """ + newLanguage.StringId + @""", - ""attributes"": { - ""isoCode"": """ + newLanguage.IsoCode + @" (changed)"" - }, - ""relationships"": { - ""lyrics"": { - ""links"": { - ""self"": ""http://localhost/textLanguages/" + newLanguage.StringId + @"/relationships/lyrics"", - ""related"": ""http://localhost/textLanguages/" + newLanguage.StringId + @"/lyrics"" + responseDocument.Should().BeJson($$""" + { + "jsonapi": { + "version": "1.1", + "ext": [ + "https://jsonapi.org/ext/atomic" + ] + }, + "links": { + "self": "http://localhost/operations" + }, + "atomic:results": [ + { + "data": null + }, + { + "data": { + "type": "textLanguages", + "id": "{{newLanguage.StringId}}", + "attributes": { + "isoCode": "{{newLanguage.IsoCode}} (changed)" + }, + "relationships": { + "lyrics": { + "links": { + "self": "http://localhost/textLanguages/{{newLanguage.StringId}}/relationships/lyrics", + "related": "http://localhost/textLanguages/{{newLanguage.StringId}}/lyrics" + } + } + }, + "links": { + "self": "http://localhost/textLanguages/{{newLanguage.StringId}}" + } + } + } + ] } - } - }, - ""links"": { - ""self"": ""http://localhost/textLanguages/" + newLanguage.StringId + @""" - } - } - } - ] -}"); + """); } [Fact] @@ -159,27 +161,29 @@ public async Task Includes_version_with_ext_on_error_at_operations_endpoint() string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"", - ""ext"": [ - ""https://jsonapi.org/ext/atomic"" - ] - }, - ""links"": { - ""self"": ""http://localhost/operations"" - }, - ""errors"": [ - { - ""id"": """ + errorId + @""", - ""status"": ""404"", - ""title"": ""The requested resource does not exist."", - ""detail"": ""Resource of type 'musicTracks' with ID '" + musicTrackId + @"' does not exist."", - ""source"": { - ""pointer"": ""/atomic:operations[0]"" - } - } - ] -}"); + responseDocument.Should().BeJson($$""" + { + "jsonapi": { + "version": "1.1", + "ext": [ + "https://jsonapi.org/ext/atomic" + ] + }, + "links": { + "self": "http://localhost/operations" + }, + "errors": [ + { + "id": "{{errorId}}", + "status": "404", + "title": "The requested resource does not exist.", + "detail": "Resource of type 'musicTracks' with ID '{{musicTrackId}}' does not exist.", + "source": { + "pointer": "/atomic:operations[0]" + } + } + ] + } + """); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs index 30099131ce..14e1e4fd61 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -120,196 +120,214 @@ await _testContext.RunOnDatabaseAsync(async dbContext => logLines.Should().BeEquivalentTo(new[] { - $@"[TRACE] Received POST request at 'http://localhost/operations' with body: <<{{ - ""atomic:operations"": [ - {{ - ""op"": ""update"", - ""data"": {{ - ""type"": ""musicTracks"", - ""id"": ""{existingTrack.StringId}"", - ""attributes"": {{ - ""genre"": ""{newGenre}"" - }}, - ""relationships"": {{ - ""lyric"": {{ - ""data"": {{ - ""type"": ""lyrics"", - ""id"": ""{existingLyric.StringId}"" - }} - }}, - ""ownedBy"": {{ - ""data"": {{ - ""type"": ""recordCompanies"", - ""id"": ""{existingCompany.StringId}"" - }} - }}, - ""performers"": {{ - ""data"": [ - {{ - ""type"": ""performers"", - ""id"": ""{existingPerformer.StringId}"" - }} - ] - }} - }} - }} - }} - ] -}}>>", - $@"[TRACE] Entering PostOperationsAsync(operations: [ - {{ - ""Resource"": {{ - ""Id"": ""{existingTrack.StringId}"", - ""Genre"": ""{newGenre}"", - ""ReleasedAt"": ""0001-01-01T00:00:00+00:00"", - ""Lyric"": {{ - ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingLyric.Id}, - ""StringId"": ""{existingLyric.StringId}"" - }}, - ""OwnedBy"": {{ - ""Tracks"": [], - ""Id"": {existingCompany.Id}, - ""StringId"": ""{existingCompany.StringId}"" - }}, - ""Performers"": [ - {{ - ""BornAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingPerformer.Id}, - ""StringId"": ""{existingPerformer.StringId}"" - }} - ], - ""OccursIn"": [], - ""StringId"": ""{existingTrack.StringId}"" - }}, - ""TargetedFields"": {{ - ""Attributes"": [ - ""genre"" - ], - ""Relationships"": [ - ""lyric"", - ""ownedBy"", - ""performers"" - ] - }}, - ""Request"": {{ - ""Kind"": ""AtomicOperations"", - ""PrimaryId"": ""{existingTrack.StringId}"", - ""PrimaryResourceType"": ""musicTracks"", - ""IsCollection"": false, - ""IsReadOnly"": false, - ""WriteOperation"": ""UpdateResource"" - }} - }} -])", - $@"[TRACE] Entering UpdateAsync(id: {existingTrack.StringId}, resource: {{ - ""Id"": ""{existingTrack.StringId}"", - ""Genre"": ""{newGenre}"", - ""ReleasedAt"": ""0001-01-01T00:00:00+00:00"", - ""Lyric"": {{ - ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingLyric.Id}, - ""StringId"": ""{existingLyric.StringId}"" - }}, - ""OwnedBy"": {{ - ""Tracks"": [], - ""Id"": {existingCompany.Id}, - ""StringId"": ""{existingCompany.StringId}"" - }}, - ""Performers"": [ - {{ - ""BornAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingPerformer.Id}, - ""StringId"": ""{existingPerformer.StringId}"" - }} - ], - ""OccursIn"": [], - ""StringId"": ""{existingTrack.StringId}"" -}})", - $@"[TRACE] Entering GetForUpdateAsync(queryLayer: QueryLayer -{{ - Include: lyric,ownedBy,performers - Filter: equals(id,'{existingTrack.StringId}') -}} -)", - $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer -{{ - Include: lyric,ownedBy,performers - Filter: equals(id,'{existingTrack.StringId}') -}} -)", - $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer -{{ - Include: lyric,ownedBy,performers - Filter: equals(id,'{existingTrack.StringId}') -}} -)", - $@"[TRACE] Entering UpdateAsync(resourceFromRequest: {{ - ""Id"": ""{existingTrack.StringId}"", - ""Genre"": ""{newGenre}"", - ""ReleasedAt"": ""0001-01-01T00:00:00+00:00"", - ""Lyric"": {{ - ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingLyric.Id}, - ""StringId"": ""{existingLyric.StringId}"" - }}, - ""OwnedBy"": {{ - ""Tracks"": [], - ""Id"": {existingCompany.Id}, - ""StringId"": ""{existingCompany.StringId}"" - }}, - ""Performers"": [ - {{ - ""BornAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingPerformer.Id}, - ""StringId"": ""{existingPerformer.StringId}"" - }} - ], - ""OccursIn"": [], - ""StringId"": ""{existingTrack.StringId}"" -}}, resourceFromDatabase: {{ - ""Id"": ""{existingTrack.StringId}"", - ""Title"": ""{existingTrack.Title}"", - ""LengthInSeconds"": {JsonSerializer.Serialize(existingTrack.LengthInSeconds)}, - ""Genre"": ""{existingTrack.Genre}"", - ""ReleasedAt"": {JsonSerializer.Serialize(existingTrack.ReleasedAt)}, - ""Lyric"": {{ - ""Format"": ""{existingTrack.Lyric.Format}"", - ""Text"": {JsonSerializer.Serialize(existingTrack.Lyric.Text)}, - ""CreatedAt"": ""0001-01-01T00:00:00+00:00"", - ""Id"": {existingTrack.Lyric.Id}, - ""StringId"": ""{existingTrack.Lyric.StringId}"" - }}, - ""OwnedBy"": {{ - ""Name"": ""{existingTrack.OwnedBy.Name}"", - ""CountryOfResidence"": ""{existingTrack.OwnedBy.CountryOfResidence}"", - ""Tracks"": [ - null - ], - ""Id"": {existingTrack.OwnedBy.Id}, - ""StringId"": ""{existingTrack.OwnedBy.StringId}"" - }}, - ""Performers"": [ - {{ - ""ArtistName"": ""{existingTrack.Performers[0].ArtistName}"", - ""BornAt"": {JsonSerializer.Serialize(existingTrack.Performers[0].BornAt)}, - ""Id"": {existingTrack.Performers[0].Id}, - ""StringId"": ""{existingTrack.Performers[0].StringId}"" - }} - ], - ""OccursIn"": [], - ""StringId"": ""{existingTrack.StringId}"" -}})", - $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer -{{ - Filter: equals(id,'{existingTrack.StringId}') -}} -)", - $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer -{{ - Filter: equals(id,'{existingTrack.StringId}') -}} -)" + $$""" + [TRACE] Received POST request at 'http://localhost/operations' with body: <<{ + "atomic:operations": [ + { + "op": "update", + "data": { + "type": "musicTracks", + "id": "{{existingTrack.StringId}}", + "attributes": { + "genre": "{{newGenre}}" + }, + "relationships": { + "lyric": { + "data": { + "type": "lyrics", + "id": "{{existingLyric.StringId}}" + } + }, + "ownedBy": { + "data": { + "type": "recordCompanies", + "id": "{{existingCompany.StringId}}" + } + }, + "performers": { + "data": [ + { + "type": "performers", + "id": "{{existingPerformer.StringId}}" + } + ] + } + } + } + } + ] + }>> + """, + $$""" + [TRACE] Entering PostOperationsAsync(operations: [ + { + "Resource": { + "Id": "{{existingTrack.StringId}}", + "Genre": "{{newGenre}}", + "ReleasedAt": "0001-01-01T00:00:00+00:00", + "Lyric": { + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingLyric.Id}}, + "StringId": "{{existingLyric.StringId}}" + }, + "OwnedBy": { + "Tracks": [], + "Id": {{existingCompany.Id}}, + "StringId": "{{existingCompany.StringId}}" + }, + "Performers": [ + { + "BornAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingPerformer.Id}}, + "StringId": "{{existingPerformer.StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }, + "TargetedFields": { + "Attributes": [ + "genre" + ], + "Relationships": [ + "lyric", + "ownedBy", + "performers" + ] + }, + "Request": { + "Kind": "AtomicOperations", + "PrimaryId": "{{existingTrack.StringId}}", + "PrimaryResourceType": "musicTracks", + "IsCollection": false, + "IsReadOnly": false, + "WriteOperation": "UpdateResource" + } + } + ]) + """, + $$""" + [TRACE] Entering UpdateAsync(id: {{existingTrack.StringId}}, resource: { + "Id": "{{existingTrack.StringId}}", + "Genre": "{{newGenre}}", + "ReleasedAt": "0001-01-01T00:00:00+00:00", + "Lyric": { + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingLyric.Id}}, + "StringId": "{{existingLyric.StringId}}" + }, + "OwnedBy": { + "Tracks": [], + "Id": {{existingCompany.Id}}, + "StringId": "{{existingCompany.StringId}}" + }, + "Performers": [ + { + "BornAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingPerformer.Id}}, + "StringId": "{{existingPerformer.StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }) + """, + $$""" + [TRACE] Entering GetForUpdateAsync(queryLayer: QueryLayer + { + Include: lyric,ownedBy,performers + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Include: lyric,ownedBy,performers + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Include: lyric,ownedBy,performers + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering UpdateAsync(resourceFromRequest: { + "Id": "{{existingTrack.StringId}}", + "Genre": "{{newGenre}}", + "ReleasedAt": "0001-01-01T00:00:00+00:00", + "Lyric": { + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingLyric.Id}}, + "StringId": "{{existingLyric.StringId}}" + }, + "OwnedBy": { + "Tracks": [], + "Id": {{existingCompany.Id}}, + "StringId": "{{existingCompany.StringId}}" + }, + "Performers": [ + { + "BornAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingPerformer.Id}}, + "StringId": "{{existingPerformer.StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }, resourceFromDatabase: { + "Id": "{{existingTrack.StringId}}", + "Title": "{{existingTrack.Title}}", + "LengthInSeconds": {{JsonSerializer.Serialize(existingTrack.LengthInSeconds)}}, + "Genre": "{{existingTrack.Genre}}", + "ReleasedAt": {{JsonSerializer.Serialize(existingTrack.ReleasedAt)}}, + "Lyric": { + "Format": "{{existingTrack.Lyric.Format}}", + "Text": {{JsonSerializer.Serialize(existingTrack.Lyric.Text)}}, + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingTrack.Lyric.Id}}, + "StringId": "{{existingTrack.Lyric.StringId}}" + }, + "OwnedBy": { + "Name": "{{existingTrack.OwnedBy.Name}}", + "CountryOfResidence": "{{existingTrack.OwnedBy.CountryOfResidence}}", + "Tracks": [ + null + ], + "Id": {{existingTrack.OwnedBy.Id}}, + "StringId": "{{existingTrack.OwnedBy.StringId}}" + }, + "Performers": [ + { + "ArtistName": "{{existingTrack.Performers[0].ArtistName}}", + "BornAt": {{JsonSerializer.Serialize(existingTrack.Performers[0].BornAt)}}, + "Id": {{existingTrack.Performers[0].Id}}, + "StringId": "{{existingTrack.Performers[0].StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """ }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 3641e9e23d..06ad682591 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -94,7 +94,7 @@ public async Task Logs_and_produces_error_response_on_deserialization_failure() var loggerFactory = _testContext.Factory.Services.GetRequiredService(); loggerFactory.Logger.Clear(); - const string requestBody = @"{ ""data"": { ""type"": """" } }"; + const string requestBody = """{ "data": { "type": "" } }"""; const string route = "/consumerArticles"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index 81aca9c43e..cc56c3980f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -161,58 +161,70 @@ await _testContext.RunOnDatabaseAsync(async dbContext => logLines.Should().BeEquivalentTo(new[] { - $@"[TRACE] Received POST request at 'http://localhost/fruitBowls/{existingBowl.StringId}/relationships/fruits' with body: <<{{ - ""data"": [ - {{ - ""type"": ""fruits"", - ""id"": ""{existingBanana.StringId}"" - }} - ] -}}>>", - $@"[TRACE] Entering PostRelationshipAsync(id: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ - {{ - ""ClrType"": ""{typeof(Fruit).FullName}"", - ""StringId"": ""{existingBanana.StringId}"" - }} -])", - $@"[TRACE] Entering AddToToManyRelationshipAsync(leftId: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ - {{ - ""ClrType"": ""{typeof(Fruit).FullName}"", - ""StringId"": ""{existingBanana.StringId}"" - }} -])", - $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer -{{ - Filter: equals(id,'{existingBanana.Id}') - Selection - {{ - FieldSelectors - {{ - id - }} - }} -}} -)", - $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer -{{ - Filter: equals(id,'{existingBanana.Id}') - Selection - {{ - FieldSelectors - {{ - id - }} - }} -}} -)", - $@"[TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {existingBowl.Id}, rightResourceIds: [ - {{ - ""Color"": ""Yellow"", - ""LengthInCentimeters"": {existingBanana.LengthInCentimeters.ToString(CultureInfo.InvariantCulture)}, - ""Id"": {existingBanana.Id}, - ""StringId"": ""{existingBanana.StringId}"" - }} -])" + $$""" + [TRACE] Received POST request at 'http://localhost/fruitBowls/{{existingBowl.StringId}}/relationships/fruits' with body: <<{ + "data": [ + { + "type": "fruits", + "id": "{{existingBanana.StringId}}" + } + ] + }>> + """, + $$""" + [TRACE] Entering PostRelationshipAsync(id: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "ClrType": "{{typeof(Fruit).FullName}}", + "StringId": "{{existingBanana.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftId: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "ClrType": "{{typeof(Fruit).FullName}}", + "StringId": "{{existingBanana.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingBanana.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingBanana.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {{existingBowl.Id}}, rightResourceIds: [ + { + "Color": "Yellow", + "LengthInCentimeters": {{existingBanana.LengthInCentimeters.ToString(CultureInfo.InvariantCulture)}}, + "Id": {{existingBanana.Id}}, + "StringId": "{{existingBanana.StringId}}" + } + ]) + """ }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); } @@ -259,62 +271,74 @@ await _testContext.RunOnDatabaseAsync(async dbContext => logLines.Should().BeEquivalentTo(new[] { - $@"[TRACE] Received POST request at 'http://localhost/fruitBowls/{existingBowl.StringId}/relationships/fruits' with body: <<{{ - ""data"": [ - {{ - ""type"": ""peaches"", - ""id"": ""{existingPeach.StringId}"" - }} - ] -}}>>", - $@"[TRACE] Entering PostRelationshipAsync(id: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ - {{ - ""Color"": ""Red/Yellow"", - ""DiameterInCentimeters"": 0, - ""Id"": {existingPeach.Id}, - ""StringId"": ""{existingPeach.StringId}"" - }} -])", - $@"[TRACE] Entering AddToToManyRelationshipAsync(leftId: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ - {{ - ""Color"": ""Red/Yellow"", - ""DiameterInCentimeters"": 0, - ""Id"": {existingPeach.Id}, - ""StringId"": ""{existingPeach.StringId}"" - }} -])", - $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer -{{ - Filter: equals(id,'{existingPeach.Id}') - Selection - {{ - FieldSelectors - {{ - id - }} - }} -}} -)", - $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer -{{ - Filter: equals(id,'{existingPeach.Id}') - Selection - {{ - FieldSelectors - {{ - id - }} - }} -}} -)", - $@"[TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {existingBowl.Id}, rightResourceIds: [ - {{ - ""Color"": ""Red/Yellow"", - ""DiameterInCentimeters"": 0, - ""Id"": {existingPeach.Id}, - ""StringId"": ""{existingPeach.StringId}"" - }} -])" + $$""" + [TRACE] Received POST request at 'http://localhost/fruitBowls/{{existingBowl.StringId}}/relationships/fruits' with body: <<{ + "data": [ + { + "type": "peaches", + "id": "{{existingPeach.StringId}}" + } + ] + }>> + """, + $$""" + [TRACE] Entering PostRelationshipAsync(id: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "Color": "Red/Yellow", + "DiameterInCentimeters": 0, + "Id": {{existingPeach.Id}}, + "StringId": "{{existingPeach.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftId: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "Color": "Red/Yellow", + "DiameterInCentimeters": 0, + "Id": {{existingPeach.Id}}, + "StringId": "{{existingPeach.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingPeach.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingPeach.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {{existingBowl.Id}}, rightResourceIds: [ + { + "Color": "Red/Yellow", + "DiameterInCentimeters": 0, + "Id": {{existingPeach.Id}}, + "StringId": "{{existingPeach.StringId}}" + } + ]) + """ }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index c96183dca0..472803c67d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -39,22 +39,24 @@ public async Task Returns_top_level_meta() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/supportTickets"", - ""first"": ""http://localhost/supportTickets"" - }, - ""data"": [], - ""meta"": { - ""license"": ""MIT"", - ""projectUrl"": ""https://github.com/json-api-dotnet/JsonApiDotNetCore/"", - ""versions"": [ - ""v4.0.0"", - ""v3.1.0"", - ""v2.5.2"", - ""v1.3.1"" - ] - } -}"); + responseDocument.Should().BeJson(""" + { + "links": { + "self": "http://localhost/supportTickets", + "first": "http://localhost/supportTickets" + }, + "data": [], + "meta": { + "license": "MIT", + "projectUrl": "https://github.com/json-api-dotnet/JsonApiDotNetCore/", + "versions": [ + "v4.0.0", + "v3.1.0", + "v2.5.2", + "v1.3.1" + ] + } + } + """); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs index 2e0417370c..6f830f873f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs @@ -1403,616 +1403,618 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson($@"{{ - ""links"": {{ - ""self"": ""{route}"", - ""first"": ""{route}"" - }}, - ""data"": [ - {{ - ""type"": ""bikes"", - ""id"": ""{bike.StringId}"", - ""attributes"": {{ - ""requiresDriverLicense"": {bike.RequiresDriverLicense.ToString().ToLowerInvariant()}, - ""gearCount"": {bike.GearCount}, - ""weight"": {bike.Weight.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""cargoBox"": {{ - ""links"": {{ - ""self"": ""/bikes/{bike.StringId}/relationships/cargoBox"", - ""related"": ""/bikes/{bike.StringId}/cargoBox"" - }}, - ""data"": {{ - ""type"": ""boxes"", - ""id"": ""{bike.CargoBox.StringId}"" - }} - }}, - ""lights"": {{ - ""links"": {{ - ""self"": ""/bikes/{bike.StringId}/relationships/lights"", - ""related"": ""/bikes/{bike.StringId}/lights"" - }}, - ""data"": [ - {{ - ""type"": ""bicycleLights"", - ""id"": ""{bike.Lights.ElementAt(0).StringId}"" - }} - ] - }}, - ""manufacturer"": {{ - ""links"": {{ - ""self"": ""/bikes/{bike.StringId}/relationships/manufacturer"", - ""related"": ""/bikes/{bike.StringId}/manufacturer"" - }}, - ""data"": {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{bike.Manufacturer.StringId}"" - }} - }}, - ""wheels"": {{ - ""links"": {{ - ""self"": ""/bikes/{bike.StringId}/relationships/wheels"", - ""related"": ""/bikes/{bike.StringId}/wheels"" - }}, - ""data"": [ - {{ - ""type"": ""carbonWheels"", - ""id"": ""{bike.Wheels.OfType().ElementAt(0).StringId}"" - }} - ] - }} - }}, - ""links"": {{ - ""self"": ""/bikes/{bike.StringId}"" - }} - }}, - {{ - ""type"": ""cars"", - ""id"": ""{car.StringId}"", - ""attributes"": {{ - ""seatCount"": {car.SeatCount}, - ""requiresDriverLicense"": {car.RequiresDriverLicense.ToString().ToLowerInvariant()}, - ""licensePlate"": ""{car.LicensePlate}"", - ""weight"": {car.Weight.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""features"": {{ - ""links"": {{ - ""self"": ""/cars/{car.StringId}/relationships/features"", - ""related"": ""/cars/{car.StringId}/features"" - }}, - ""data"": [ - {{ - ""type"": ""genericFeatures"", - ""id"": ""{car.Features.ElementAt(0).StringId}"" - }} - ] - }}, - ""engine"": {{ - ""links"": {{ - ""self"": ""/cars/{car.StringId}/relationships/engine"", - ""related"": ""/cars/{car.StringId}/engine"" - }}, - ""data"": {{ - ""type"": ""gasolineEngines"", - ""id"": ""{car.Engine.StringId}"" - }} - }}, - ""navigationSystem"": {{ - ""links"": {{ - ""self"": ""/cars/{car.StringId}/relationships/navigationSystem"", - ""related"": ""/cars/{car.StringId}/navigationSystem"" - }}, - ""data"": {{ - ""type"": ""navigationSystems"", - ""id"": ""{car.NavigationSystem.StringId}"" - }} - }}, - ""manufacturer"": {{ - ""links"": {{ - ""self"": ""/cars/{car.StringId}/relationships/manufacturer"", - ""related"": ""/cars/{car.StringId}/manufacturer"" - }}, - ""data"": {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{car.Manufacturer.StringId}"" - }} - }}, - ""wheels"": {{ - ""links"": {{ - ""self"": ""/cars/{car.StringId}/relationships/wheels"", - ""related"": ""/cars/{car.StringId}/wheels"" - }}, - ""data"": [ - {{ - ""type"": ""carbonWheels"", - ""id"": ""{car.Wheels.OfType().ElementAt(0).StringId}"" - }} - ] - }} - }}, - ""links"": {{ - ""self"": ""/cars/{car.StringId}"" - }} - }}, - {{ - ""type"": ""tandems"", - ""id"": ""{tandem.StringId}"", - ""attributes"": {{ - ""passengerCount"": {tandem.PassengerCount}, - ""requiresDriverLicense"": {tandem.RequiresDriverLicense.ToString().ToLowerInvariant()}, - ""gearCount"": {tandem.GearCount}, - ""weight"": {tandem.Weight.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""features"": {{ - ""links"": {{ - ""self"": ""/tandems/{tandem.StringId}/relationships/features"", - ""related"": ""/tandems/{tandem.StringId}/features"" - }}, - ""data"": [ - {{ - ""type"": ""genericFeatures"", - ""id"": ""{tandem.Features.ElementAt(0).StringId}"" - }} - ] - }}, - ""cargoBox"": {{ - ""links"": {{ - ""self"": ""/tandems/{tandem.StringId}/relationships/cargoBox"", - ""related"": ""/tandems/{tandem.StringId}/cargoBox"" - }}, - ""data"": {{ - ""type"": ""boxes"", - ""id"": ""{tandem.CargoBox.StringId}"" - }} - }}, - ""lights"": {{ - ""links"": {{ - ""self"": ""/tandems/{tandem.StringId}/relationships/lights"", - ""related"": ""/tandems/{tandem.StringId}/lights"" - }}, - ""data"": [ - {{ - ""type"": ""bicycleLights"", - ""id"": ""{tandem.Lights.ElementAt(0).StringId}"" - }} - ] - }}, - ""manufacturer"": {{ - ""links"": {{ - ""self"": ""/tandems/{tandem.StringId}/relationships/manufacturer"", - ""related"": ""/tandems/{tandem.StringId}/manufacturer"" - }}, - ""data"": {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{tandem.Manufacturer.StringId}"" - }} - }}, - ""wheels"": {{ - ""links"": {{ - ""self"": ""/tandems/{tandem.StringId}/relationships/wheels"", - ""related"": ""/tandems/{tandem.StringId}/wheels"" - }}, - ""data"": [ - {{ - ""type"": ""chromeWheels"", - ""id"": ""{tandem.Wheels.ElementAt(0).StringId}"" - }} - ] - }} - }}, - ""links"": {{ - ""self"": ""/tandems/{tandem.StringId}"" - }} - }}, - {{ - ""type"": ""trucks"", - ""id"": ""{truck.StringId}"", - ""attributes"": {{ - ""loadingCapacity"": {truck.LoadingCapacity.ToString(CultureInfo.InvariantCulture)}, - ""requiresDriverLicense"": {truck.RequiresDriverLicense.ToString().ToLowerInvariant()}, - ""licensePlate"": ""{truck.LicensePlate}"", - ""weight"": {truck.Weight.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""sleepingArea"": {{ - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}/relationships/sleepingArea"", - ""related"": ""/trucks/{truck.StringId}/sleepingArea"" - }}, - ""data"": {{ - ""type"": ""boxes"", - ""id"": ""{truck.SleepingArea.StringId}"" - }} - }}, - ""features"": {{ - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}/relationships/features"", - ""related"": ""/trucks/{truck.StringId}/features"" - }}, - ""data"": [ - {{ - ""type"": ""genericFeatures"", - ""id"": ""{truck.Features.ElementAt(0).StringId}"" - }} - ] - }}, - ""engine"": {{ - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}/relationships/engine"", - ""related"": ""/trucks/{truck.StringId}/engine"" - }}, - ""data"": {{ - ""type"": ""dieselEngines"", - ""id"": ""{truck.Engine.StringId}"" - }} - }}, - ""navigationSystem"": {{ - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}/relationships/navigationSystem"", - ""related"": ""/trucks/{truck.StringId}/navigationSystem"" - }}, - ""data"": {{ - ""type"": ""navigationSystems"", - ""id"": ""{truck.NavigationSystem.StringId}"" - }} - }}, - ""manufacturer"": {{ - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}/relationships/manufacturer"", - ""related"": ""/trucks/{truck.StringId}/manufacturer"" - }}, - ""data"": {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{truck.Manufacturer.StringId}"" - }} - }}, - ""wheels"": {{ - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}/relationships/wheels"", - ""related"": ""/trucks/{truck.StringId}/wheels"" - }}, - ""data"": [ - {{ - ""type"": ""chromeWheels"", - ""id"": ""{truck.Wheels.ElementAt(0).StringId}"" - }} - ] - }} - }}, - ""links"": {{ - ""self"": ""/trucks/{truck.StringId}"" - }} - }} - ], - ""included"": [ - {{ - ""type"": ""boxes"", - ""id"": ""{bike.CargoBox.StringId}"", - ""attributes"": {{ - ""width"": {bike.CargoBox.Width.ToString(CultureInfo.InvariantCulture)}, - ""height"": {bike.CargoBox.Height.ToString(CultureInfo.InvariantCulture)}, - ""depth"": {bike.CargoBox.Depth.ToString(CultureInfo.InvariantCulture)} - }} - }}, - {{ - ""type"": ""bicycleLights"", - ""id"": ""{bike.Lights.ElementAt(0).StringId}"", - ""attributes"": {{ - ""color"": ""{bike.Lights.ElementAt(0).Color}"" - }} - }}, - {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{bike.Manufacturer.StringId}"", - ""attributes"": {{ - ""name"": ""{bike.Manufacturer.Name}"" - }} - }}, - {{ - ""type"": ""carbonWheels"", - ""id"": ""{bike.Wheels.ElementAt(0).StringId}"", - ""attributes"": {{ - ""hasTube"": {bike.Wheels.Cast().ElementAt(0).HasTube.ToString().ToLowerInvariant()}, - ""radius"": {bike.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""vehicle"": {{ - ""links"": {{ - ""self"": ""/carbonWheels/{bike.Wheels.ElementAt(0).StringId}/relationships/vehicle"", - ""related"": ""/carbonWheels/{bike.Wheels.ElementAt(0).StringId}/vehicle"" - }} - }} - }}, - ""links"": {{ - ""self"": ""/carbonWheels/{bike.Wheels.ElementAt(0).StringId}"" - }} - }}, - {{ - ""type"": ""genericFeatures"", - ""id"": ""{car.Features.ElementAt(0).StringId}"", - ""attributes"": {{ - ""description"": ""{car.Features.ElementAt(0).Description}"" - }}, - ""relationships"": {{ - ""properties"": {{ - ""data"": [ - {{ - ""type"": ""numberProperties"", - ""id"": ""{car.Features.ElementAt(0).Properties.ElementAt(0).StringId}"" - }} - ] - }} - }} - }}, - {{ - ""type"": ""numberProperties"", - ""id"": ""{car.Features.ElementAt(0).Properties.ElementAt(0).StringId}"", - ""attributes"": {{ - ""name"": ""{car.Features.ElementAt(0).Properties.ElementAt(0).Name}"" - }}, - ""relationships"": {{ - ""value"": {{ - ""data"": {{ - ""type"": ""numberValues"", - ""id"": ""{car.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}"" - }} - }} - }} - }}, - {{ - ""type"": ""numberValues"", - ""id"": ""{car.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}"", - ""attributes"": {{ - ""content"": {car.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.Content.ToString(CultureInfo.InvariantCulture)} - }} - }}, - {{ - ""type"": ""gasolineEngines"", - ""id"": ""{car.Engine.StringId}"", - ""attributes"": {{ - ""isHydrocarbonBased"": {car.Engine.IsHydrocarbonBased.ToString().ToLowerInvariant()}, - ""serialCode"": ""{((GasolineEngine)car.Engine).SerialCode}"", - ""volatility"": {((GasolineEngine)car.Engine).Volatility.ToString(CultureInfo.InvariantCulture)}, - ""capacity"": {car.Engine.Capacity.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""cylinders"": {{ - ""links"": {{ - ""self"": ""/gasolineEngines/{car.Engine.StringId}/relationships/cylinders"", - ""related"": ""/gasolineEngines/{car.Engine.StringId}/cylinders"" - }}, - ""data"": [ - {{ - ""type"": ""cylinders"", - ""id"": ""{((GasolineEngine)car.Engine).Cylinders.ElementAt(0).StringId}"" - }} - ] - }} - }}, - ""links"": {{ - ""self"": ""/gasolineEngines/{car.Engine.StringId}"" - }} - }}, - {{ - ""type"": ""cylinders"", - ""id"": ""{((GasolineEngine)car.Engine).Cylinders.ElementAt(0).StringId}"", - ""attributes"": {{ - ""sparkPlugCount"": {((GasolineEngine)car.Engine).Cylinders.ElementAt(0).SparkPlugCount} - }} - }}, - {{ - ""type"": ""navigationSystems"", - ""id"": ""{car.NavigationSystem.StringId}"", - ""attributes"": {{ - ""modelType"": ""{car.NavigationSystem.ModelType}"" - }} - }}, - {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{car.Manufacturer.StringId}"", - ""attributes"": {{ - ""name"": ""{car.Manufacturer.Name}"" - }} - }}, - {{ - ""type"": ""carbonWheels"", - ""id"": ""{car.Wheels.ElementAt(0).StringId}"", - ""attributes"": {{ - ""hasTube"": {car.Wheels.OfType().ElementAt(0).HasTube.ToString().ToLowerInvariant()}, - ""radius"": {car.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""vehicle"": {{ - ""links"": {{ - ""self"": ""/carbonWheels/{car.Wheels.ElementAt(0).StringId}/relationships/vehicle"", - ""related"": ""/carbonWheels/{car.Wheels.ElementAt(0).StringId}/vehicle"" - }} - }} - }}, - ""links"": {{ - ""self"": ""/carbonWheels/{car.Wheels.ElementAt(0).StringId}"" - }} - }}, - {{ - ""type"": ""genericFeatures"", - ""id"": ""{tandem.Features.ElementAt(0).StringId}"", - ""attributes"": {{ - ""description"": ""{tandem.Features.ElementAt(0).Description}"" - }}, - ""relationships"": {{ - ""properties"": {{ - ""data"": [ - {{ - ""type"": ""stringProperties"", - ""id"": ""{tandem.Features.ElementAt(0).Properties.ElementAt(0).StringId}"" - }} - ] - }} - }} - }}, - {{ - ""type"": ""stringProperties"", - ""id"": ""{tandem.Features.ElementAt(0).Properties.ElementAt(0).StringId}"", - ""attributes"": {{ - ""name"": ""{tandem.Features.ElementAt(0).Properties.ElementAt(0).Name}"" - }}, - ""relationships"": {{ - ""value"": {{ - ""data"": {{ - ""type"": ""stringValues"", - ""id"": ""{tandem.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}"" - }} - }} - }} - }}, - {{ - ""type"": ""stringValues"", - ""id"": ""{tandem.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}"", - ""attributes"": {{ - ""content"": ""{tandem.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.Content}"" - }} - }}, - {{ - ""type"": ""boxes"", - ""id"": ""{tandem.CargoBox.StringId}"", - ""attributes"": {{ - ""width"": {tandem.CargoBox.Width.ToString(CultureInfo.InvariantCulture)}, - ""height"": {tandem.CargoBox.Height.ToString(CultureInfo.InvariantCulture)}, - ""depth"": {tandem.CargoBox.Depth.ToString(CultureInfo.InvariantCulture)} - }} - }}, - {{ - ""type"": ""bicycleLights"", - ""id"": ""{tandem.Lights.ElementAt(0).StringId}"", - ""attributes"": {{ - ""color"": ""{tandem.Lights.ElementAt(0).Color}"" - }} - }}, - {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{tandem.Manufacturer.StringId}"", - ""attributes"": {{ - ""name"": ""{tandem.Manufacturer.Name}"" - }} - }}, - {{ - ""type"": ""chromeWheels"", - ""id"": ""{tandem.Wheels.ElementAt(0).StringId}"", - ""attributes"": {{ - ""paintColor"": ""{tandem.Wheels.OfType().ElementAt(0).PaintColor}"", - ""radius"": {tandem.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""vehicle"": {{ - ""links"": {{ - ""self"": ""/chromeWheels/{tandem.Wheels.ElementAt(0).StringId}/relationships/vehicle"", - ""related"": ""/chromeWheels/{tandem.Wheels.ElementAt(0).StringId}/vehicle"" - }} - }} - }}, - ""links"": {{ - ""self"": ""/chromeWheels/{tandem.Wheels.ElementAt(0).StringId}"" - }} - }}, - {{ - ""type"": ""boxes"", - ""id"": ""{truck.SleepingArea.StringId}"", - ""attributes"": {{ - ""width"": {truck.SleepingArea.Width.ToString(CultureInfo.InvariantCulture)}, - ""height"": {truck.SleepingArea.Height.ToString(CultureInfo.InvariantCulture)}, - ""depth"": {truck.SleepingArea.Depth.ToString(CultureInfo.InvariantCulture)} - }} - }}, - {{ - ""type"": ""genericFeatures"", - ""id"": ""{truck.Features.ElementAt(0).StringId}"", - ""attributes"": {{ - ""description"": ""{truck.Features.ElementAt(0).Description}"" - }}, - ""relationships"": {{ - ""properties"": {{ - ""data"": [ - {{ - ""type"": ""stringProperties"", - ""id"": ""{truck.Features.ElementAt(0).Properties.ElementAt(0).StringId}"" - }} - ] - }} - }} - }}, - {{ - ""type"": ""stringProperties"", - ""id"": ""{truck.Features.ElementAt(0).Properties.ElementAt(0).StringId}"", - ""attributes"": {{ - ""name"": ""{truck.Features.ElementAt(0).Properties.ElementAt(0).Name}"" - }}, - ""relationships"": {{ - ""value"": {{ - ""data"": {{ - ""type"": ""stringValues"", - ""id"": ""{truck.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}"" - }} - }} - }} - }}, - {{ - ""type"": ""stringValues"", - ""id"": ""{truck.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}"", - ""attributes"": {{ - ""content"": ""{truck.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.Content}"" - }} - }}, - {{ - ""type"": ""dieselEngines"", - ""id"": ""{truck.Engine.StringId}"", - ""attributes"": {{ - ""isHydrocarbonBased"": {truck.Engine.IsHydrocarbonBased.ToString().ToLowerInvariant()}, - ""serialCode"": ""{((DieselEngine)truck.Engine).SerialCode}"", - ""viscosity"": {((DieselEngine)truck.Engine).Viscosity.ToString(CultureInfo.InvariantCulture)}, - ""capacity"": {truck.Engine.Capacity.ToString(CultureInfo.InvariantCulture)} - }}, - ""links"": {{ - ""self"": ""/dieselEngines/{truck.Engine.StringId}"" - }} - }}, - {{ - ""type"": ""navigationSystems"", - ""id"": ""{truck.NavigationSystem.StringId}"", - ""attributes"": {{ - ""modelType"": ""{truck.NavigationSystem.ModelType}"" - }} - }}, - {{ - ""type"": ""vehicleManufacturers"", - ""id"": ""{truck.Manufacturer.StringId}"", - ""attributes"": {{ - ""name"": ""{truck.Manufacturer.Name}"" - }} - }}, - {{ - ""type"": ""chromeWheels"", - ""id"": ""{truck.Wheels.ElementAt(0).StringId}"", - ""attributes"": {{ - ""paintColor"": ""{truck.Wheels.OfType().ElementAt(0).PaintColor}"", - ""radius"": {truck.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)} - }}, - ""relationships"": {{ - ""vehicle"": {{ - ""links"": {{ - ""self"": ""/chromeWheels/{truck.Wheels.ElementAt(0).StringId}/relationships/vehicle"", - ""related"": ""/chromeWheels/{truck.Wheels.ElementAt(0).StringId}/vehicle"" - }} - }} - }}, - ""links"": {{ - ""self"": ""/chromeWheels/{truck.Wheels.ElementAt(0).StringId}"" - }} - }} - ] -}}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "{{route}}", + "first": "{{route}}" + }, + "data": [ + { + "type": "bikes", + "id": "{{bike.StringId}}", + "attributes": { + "requiresDriverLicense": {{bike.RequiresDriverLicense.ToString().ToLowerInvariant()}}, + "gearCount": {{bike.GearCount}}, + "weight": {{bike.Weight.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "cargoBox": { + "links": { + "self": "/bikes/{{bike.StringId}}/relationships/cargoBox", + "related": "/bikes/{{bike.StringId}}/cargoBox" + }, + "data": { + "type": "boxes", + "id": "{{bike.CargoBox.StringId}}" + } + }, + "lights": { + "links": { + "self": "/bikes/{{bike.StringId}}/relationships/lights", + "related": "/bikes/{{bike.StringId}}/lights" + }, + "data": [ + { + "type": "bicycleLights", + "id": "{{bike.Lights.ElementAt(0).StringId}}" + } + ] + }, + "manufacturer": { + "links": { + "self": "/bikes/{{bike.StringId}}/relationships/manufacturer", + "related": "/bikes/{{bike.StringId}}/manufacturer" + }, + "data": { + "type": "vehicleManufacturers", + "id": "{{bike.Manufacturer.StringId}}" + } + }, + "wheels": { + "links": { + "self": "/bikes/{{bike.StringId}}/relationships/wheels", + "related": "/bikes/{{bike.StringId}}/wheels" + }, + "data": [ + { + "type": "carbonWheels", + "id": "{{bike.Wheels.OfType().ElementAt(0).StringId}}" + } + ] + } + }, + "links": { + "self": "/bikes/{{bike.StringId}}" + } + }, + { + "type": "cars", + "id": "{{car.StringId}}", + "attributes": { + "seatCount": {{car.SeatCount}}, + "requiresDriverLicense": {{car.RequiresDriverLicense.ToString().ToLowerInvariant()}}, + "licensePlate": "{{car.LicensePlate}}", + "weight": {{car.Weight.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "features": { + "links": { + "self": "/cars/{{car.StringId}}/relationships/features", + "related": "/cars/{{car.StringId}}/features" + }, + "data": [ + { + "type": "genericFeatures", + "id": "{{car.Features.ElementAt(0).StringId}}" + } + ] + }, + "engine": { + "links": { + "self": "/cars/{{car.StringId}}/relationships/engine", + "related": "/cars/{{car.StringId}}/engine" + }, + "data": { + "type": "gasolineEngines", + "id": "{{car.Engine.StringId}}" + } + }, + "navigationSystem": { + "links": { + "self": "/cars/{{car.StringId}}/relationships/navigationSystem", + "related": "/cars/{{car.StringId}}/navigationSystem" + }, + "data": { + "type": "navigationSystems", + "id": "{{car.NavigationSystem.StringId}}" + } + }, + "manufacturer": { + "links": { + "self": "/cars/{{car.StringId}}/relationships/manufacturer", + "related": "/cars/{{car.StringId}}/manufacturer" + }, + "data": { + "type": "vehicleManufacturers", + "id": "{{car.Manufacturer.StringId}}" + } + }, + "wheels": { + "links": { + "self": "/cars/{{car.StringId}}/relationships/wheels", + "related": "/cars/{{car.StringId}}/wheels" + }, + "data": [ + { + "type": "carbonWheels", + "id": "{{car.Wheels.OfType().ElementAt(0).StringId}}" + } + ] + } + }, + "links": { + "self": "/cars/{{car.StringId}}" + } + }, + { + "type": "tandems", + "id": "{{tandem.StringId}}", + "attributes": { + "passengerCount": {{tandem.PassengerCount}}, + "requiresDriverLicense": {{tandem.RequiresDriverLicense.ToString().ToLowerInvariant()}}, + "gearCount": {{tandem.GearCount}}, + "weight": {{tandem.Weight.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "features": { + "links": { + "self": "/tandems/{{tandem.StringId}}/relationships/features", + "related": "/tandems/{{tandem.StringId}}/features" + }, + "data": [ + { + "type": "genericFeatures", + "id": "{{tandem.Features.ElementAt(0).StringId}}" + } + ] + }, + "cargoBox": { + "links": { + "self": "/tandems/{{tandem.StringId}}/relationships/cargoBox", + "related": "/tandems/{{tandem.StringId}}/cargoBox" + }, + "data": { + "type": "boxes", + "id": "{{tandem.CargoBox.StringId}}" + } + }, + "lights": { + "links": { + "self": "/tandems/{{tandem.StringId}}/relationships/lights", + "related": "/tandems/{{tandem.StringId}}/lights" + }, + "data": [ + { + "type": "bicycleLights", + "id": "{{tandem.Lights.ElementAt(0).StringId}}" + } + ] + }, + "manufacturer": { + "links": { + "self": "/tandems/{{tandem.StringId}}/relationships/manufacturer", + "related": "/tandems/{{tandem.StringId}}/manufacturer" + }, + "data": { + "type": "vehicleManufacturers", + "id": "{{tandem.Manufacturer.StringId}}" + } + }, + "wheels": { + "links": { + "self": "/tandems/{{tandem.StringId}}/relationships/wheels", + "related": "/tandems/{{tandem.StringId}}/wheels" + }, + "data": [ + { + "type": "chromeWheels", + "id": "{{tandem.Wheels.ElementAt(0).StringId}}" + } + ] + } + }, + "links": { + "self": "/tandems/{{tandem.StringId}}" + } + }, + { + "type": "trucks", + "id": "{{truck.StringId}}", + "attributes": { + "loadingCapacity": {{truck.LoadingCapacity.ToString(CultureInfo.InvariantCulture)}}, + "requiresDriverLicense": {{truck.RequiresDriverLicense.ToString().ToLowerInvariant()}}, + "licensePlate": "{{truck.LicensePlate}}", + "weight": {{truck.Weight.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "sleepingArea": { + "links": { + "self": "/trucks/{{truck.StringId}}/relationships/sleepingArea", + "related": "/trucks/{{truck.StringId}}/sleepingArea" + }, + "data": { + "type": "boxes", + "id": "{{truck.SleepingArea.StringId}}" + } + }, + "features": { + "links": { + "self": "/trucks/{{truck.StringId}}/relationships/features", + "related": "/trucks/{{truck.StringId}}/features" + }, + "data": [ + { + "type": "genericFeatures", + "id": "{{truck.Features.ElementAt(0).StringId}}" + } + ] + }, + "engine": { + "links": { + "self": "/trucks/{{truck.StringId}}/relationships/engine", + "related": "/trucks/{{truck.StringId}}/engine" + }, + "data": { + "type": "dieselEngines", + "id": "{{truck.Engine.StringId}}" + } + }, + "navigationSystem": { + "links": { + "self": "/trucks/{{truck.StringId}}/relationships/navigationSystem", + "related": "/trucks/{{truck.StringId}}/navigationSystem" + }, + "data": { + "type": "navigationSystems", + "id": "{{truck.NavigationSystem.StringId}}" + } + }, + "manufacturer": { + "links": { + "self": "/trucks/{{truck.StringId}}/relationships/manufacturer", + "related": "/trucks/{{truck.StringId}}/manufacturer" + }, + "data": { + "type": "vehicleManufacturers", + "id": "{{truck.Manufacturer.StringId}}" + } + }, + "wheels": { + "links": { + "self": "/trucks/{{truck.StringId}}/relationships/wheels", + "related": "/trucks/{{truck.StringId}}/wheels" + }, + "data": [ + { + "type": "chromeWheels", + "id": "{{truck.Wheels.ElementAt(0).StringId}}" + } + ] + } + }, + "links": { + "self": "/trucks/{{truck.StringId}}" + } + } + ], + "included": [ + { + "type": "boxes", + "id": "{{bike.CargoBox.StringId}}", + "attributes": { + "width": {{bike.CargoBox.Width.ToString(CultureInfo.InvariantCulture)}}, + "height": {{bike.CargoBox.Height.ToString(CultureInfo.InvariantCulture)}}, + "depth": {{bike.CargoBox.Depth.ToString(CultureInfo.InvariantCulture)}} + } + }, + { + "type": "bicycleLights", + "id": "{{bike.Lights.ElementAt(0).StringId}}", + "attributes": { + "color": "{{bike.Lights.ElementAt(0).Color}}" + } + }, + { + "type": "vehicleManufacturers", + "id": "{{bike.Manufacturer.StringId}}", + "attributes": { + "name": "{{bike.Manufacturer.Name}}" + } + }, + { + "type": "carbonWheels", + "id": "{{bike.Wheels.ElementAt(0).StringId}}", + "attributes": { + "hasTube": {{bike.Wheels.Cast().ElementAt(0).HasTube.ToString().ToLowerInvariant()}}, + "radius": {{bike.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "vehicle": { + "links": { + "self": "/carbonWheels/{{bike.Wheels.ElementAt(0).StringId}}/relationships/vehicle", + "related": "/carbonWheels/{{bike.Wheels.ElementAt(0).StringId}}/vehicle" + } + } + }, + "links": { + "self": "/carbonWheels/{{bike.Wheels.ElementAt(0).StringId}}" + } + }, + { + "type": "genericFeatures", + "id": "{{car.Features.ElementAt(0).StringId}}", + "attributes": { + "description": "{{car.Features.ElementAt(0).Description}}" + }, + "relationships": { + "properties": { + "data": [ + { + "type": "numberProperties", + "id": "{{car.Features.ElementAt(0).Properties.ElementAt(0).StringId}}" + } + ] + } + } + }, + { + "type": "numberProperties", + "id": "{{car.Features.ElementAt(0).Properties.ElementAt(0).StringId}}", + "attributes": { + "name": "{{car.Features.ElementAt(0).Properties.ElementAt(0).Name}}" + }, + "relationships": { + "value": { + "data": { + "type": "numberValues", + "id": "{{car.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}}" + } + } + } + }, + { + "type": "numberValues", + "id": "{{car.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}}", + "attributes": { + "content": {{car.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.Content.ToString(CultureInfo.InvariantCulture)}} + } + }, + { + "type": "gasolineEngines", + "id": "{{car.Engine.StringId}}", + "attributes": { + "isHydrocarbonBased": {{car.Engine.IsHydrocarbonBased.ToString().ToLowerInvariant()}}, + "serialCode": "{{((GasolineEngine)car.Engine).SerialCode}}", + "volatility": {{((GasolineEngine)car.Engine).Volatility.ToString(CultureInfo.InvariantCulture)}}, + "capacity": {{car.Engine.Capacity.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "cylinders": { + "links": { + "self": "/gasolineEngines/{{car.Engine.StringId}}/relationships/cylinders", + "related": "/gasolineEngines/{{car.Engine.StringId}}/cylinders" + }, + "data": [ + { + "type": "cylinders", + "id": "{{((GasolineEngine)car.Engine).Cylinders.ElementAt(0).StringId}}" + } + ] + } + }, + "links": { + "self": "/gasolineEngines/{{car.Engine.StringId}}" + } + }, + { + "type": "cylinders", + "id": "{{((GasolineEngine)car.Engine).Cylinders.ElementAt(0).StringId}}", + "attributes": { + "sparkPlugCount": {{((GasolineEngine)car.Engine).Cylinders.ElementAt(0).SparkPlugCount}} + } + }, + { + "type": "navigationSystems", + "id": "{{car.NavigationSystem.StringId}}", + "attributes": { + "modelType": "{{car.NavigationSystem.ModelType}}" + } + }, + { + "type": "vehicleManufacturers", + "id": "{{car.Manufacturer.StringId}}", + "attributes": { + "name": "{{car.Manufacturer.Name}}" + } + }, + { + "type": "carbonWheels", + "id": "{{car.Wheels.ElementAt(0).StringId}}", + "attributes": { + "hasTube": {{car.Wheels.OfType().ElementAt(0).HasTube.ToString().ToLowerInvariant()}}, + "radius": {{car.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "vehicle": { + "links": { + "self": "/carbonWheels/{{car.Wheels.ElementAt(0).StringId}}/relationships/vehicle", + "related": "/carbonWheels/{{car.Wheels.ElementAt(0).StringId}}/vehicle" + } + } + }, + "links": { + "self": "/carbonWheels/{{car.Wheels.ElementAt(0).StringId}}" + } + }, + { + "type": "genericFeatures", + "id": "{{tandem.Features.ElementAt(0).StringId}}", + "attributes": { + "description": "{{tandem.Features.ElementAt(0).Description}}" + }, + "relationships": { + "properties": { + "data": [ + { + "type": "stringProperties", + "id": "{{tandem.Features.ElementAt(0).Properties.ElementAt(0).StringId}}" + } + ] + } + } + }, + { + "type": "stringProperties", + "id": "{{tandem.Features.ElementAt(0).Properties.ElementAt(0).StringId}}", + "attributes": { + "name": "{{tandem.Features.ElementAt(0).Properties.ElementAt(0).Name}}" + }, + "relationships": { + "value": { + "data": { + "type": "stringValues", + "id": "{{tandem.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}}" + } + } + } + }, + { + "type": "stringValues", + "id": "{{tandem.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}}", + "attributes": { + "content": "{{tandem.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.Content}}" + } + }, + { + "type": "boxes", + "id": "{{tandem.CargoBox.StringId}}", + "attributes": { + "width": {{tandem.CargoBox.Width.ToString(CultureInfo.InvariantCulture)}}, + "height": {{tandem.CargoBox.Height.ToString(CultureInfo.InvariantCulture)}}, + "depth": {{tandem.CargoBox.Depth.ToString(CultureInfo.InvariantCulture)}} + } + }, + { + "type": "bicycleLights", + "id": "{{tandem.Lights.ElementAt(0).StringId}}", + "attributes": { + "color": "{{tandem.Lights.ElementAt(0).Color}}" + } + }, + { + "type": "vehicleManufacturers", + "id": "{{tandem.Manufacturer.StringId}}", + "attributes": { + "name": "{{tandem.Manufacturer.Name}}" + } + }, + { + "type": "chromeWheels", + "id": "{{tandem.Wheels.ElementAt(0).StringId}}", + "attributes": { + "paintColor": "{{tandem.Wheels.OfType().ElementAt(0).PaintColor}}", + "radius": {{tandem.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "vehicle": { + "links": { + "self": "/chromeWheels/{{tandem.Wheels.ElementAt(0).StringId}}/relationships/vehicle", + "related": "/chromeWheels/{{tandem.Wheels.ElementAt(0).StringId}}/vehicle" + } + } + }, + "links": { + "self": "/chromeWheels/{{tandem.Wheels.ElementAt(0).StringId}}" + } + }, + { + "type": "boxes", + "id": "{{truck.SleepingArea.StringId}}", + "attributes": { + "width": {{truck.SleepingArea.Width.ToString(CultureInfo.InvariantCulture)}}, + "height": {{truck.SleepingArea.Height.ToString(CultureInfo.InvariantCulture)}}, + "depth": {{truck.SleepingArea.Depth.ToString(CultureInfo.InvariantCulture)}} + } + }, + { + "type": "genericFeatures", + "id": "{{truck.Features.ElementAt(0).StringId}}", + "attributes": { + "description": "{{truck.Features.ElementAt(0).Description}}" + }, + "relationships": { + "properties": { + "data": [ + { + "type": "stringProperties", + "id": "{{truck.Features.ElementAt(0).Properties.ElementAt(0).StringId}}" + } + ] + } + } + }, + { + "type": "stringProperties", + "id": "{{truck.Features.ElementAt(0).Properties.ElementAt(0).StringId}}", + "attributes": { + "name": "{{truck.Features.ElementAt(0).Properties.ElementAt(0).Name}}" + }, + "relationships": { + "value": { + "data": { + "type": "stringValues", + "id": "{{truck.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}}" + } + } + } + }, + { + "type": "stringValues", + "id": "{{truck.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.StringId}}", + "attributes": { + "content": "{{truck.Features.ElementAt(0).Properties.OfType().ElementAt(0).Value.Content}}" + } + }, + { + "type": "dieselEngines", + "id": "{{truck.Engine.StringId}}", + "attributes": { + "isHydrocarbonBased": {{truck.Engine.IsHydrocarbonBased.ToString().ToLowerInvariant()}}, + "serialCode": "{{((DieselEngine)truck.Engine).SerialCode}}", + "viscosity": {{((DieselEngine)truck.Engine).Viscosity.ToString(CultureInfo.InvariantCulture)}}, + "capacity": {{truck.Engine.Capacity.ToString(CultureInfo.InvariantCulture)}} + }, + "links": { + "self": "/dieselEngines/{{truck.Engine.StringId}}" + } + }, + { + "type": "navigationSystems", + "id": "{{truck.NavigationSystem.StringId}}", + "attributes": { + "modelType": "{{truck.NavigationSystem.ModelType}}" + } + }, + { + "type": "vehicleManufacturers", + "id": "{{truck.Manufacturer.StringId}}", + "attributes": { + "name": "{{truck.Manufacturer.Name}}" + } + }, + { + "type": "chromeWheels", + "id": "{{truck.Wheels.ElementAt(0).StringId}}", + "attributes": { + "paintColor": "{{truck.Wheels.OfType().ElementAt(0).PaintColor}}", + "radius": {{truck.Wheels.ElementAt(0).Radius.ToString(CultureInfo.InvariantCulture)}} + }, + "relationships": { + "vehicle": { + "links": { + "self": "/chromeWheels/{{truck.Wheels.ElementAt(0).StringId}}/relationships/vehicle", + "related": "/chromeWheels/{{truck.Wheels.ElementAt(0).StringId}}/vehicle" + } + } + }, + "links": { + "self": "/chromeWheels/{{truck.Wheels.ElementAt(0).StringId}}" + } + } + ] + } + """); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index 00680558a7..fdac2bfc64 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -1,5 +1,7 @@ using System.Globalization; using System.Net; +using System.Text.Encodings.Web; +using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -13,6 +15,11 @@ public sealed class SerializationTests : IClassFixture, SerializationDbContext> _testContext; private readonly SerializationFakers _fakers = new(); @@ -95,74 +102,76 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings?include=attendees"", - ""first"": ""http://localhost/meetings?include=attendees"", - ""last"": ""http://localhost/meetings?include=attendees"" - }, - ""data"": [ - { - ""type"": ""meetings"", - ""id"": """ + meeting.StringId + @""", - ""attributes"": { - ""title"": """ + meeting.Title + @""", - ""startTime"": """ + meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + meeting.Duration + @""", - ""location"": { - ""lat"": " + meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" - } - }, - ""relationships"": { - ""attendees"": { - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" - }, - ""data"": [ + responseDocument.Should().BeJson($$""" { - ""type"": ""meetingAttendees"", - ""id"": """ + meeting.Attendees[0].StringId + @""" + "links": { + "self": "http://localhost/meetings?include=attendees", + "first": "http://localhost/meetings?include=attendees", + "last": "http://localhost/meetings?include=attendees" + }, + "data": [ + { + "type": "meetings", + "id": "{{meeting.StringId}}", + "attributes": { + "title": "{{meeting.Title}}", + "startTime": "{{meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier)}}", + "duration": "{{meeting.Duration}}", + "location": { + "lat": {{meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture)}}, + "lng": {{meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture)}} + } + }, + "relationships": { + "attendees": { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}/relationships/attendees", + "related": "http://localhost/meetings/{{meeting.StringId}}/attendees" + }, + "data": [ + { + "type": "meetingAttendees", + "id": "{{meeting.Attendees[0].StringId}}" + } + ] + } + }, + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}" + } + } + ], + "included": [ + { + "type": "meetingAttendees", + "id": "{{meeting.Attendees[0].StringId}}", + "attributes": { + "displayName": {{JsonSerializer.Serialize(meeting.Attendees[0].DisplayName, UnicodeSerializerOptions)}}, + "homeAddress": { + "street": "{{meeting.Attendees[0].HomeAddress.Street}}", + "zipCode": "{{meeting.Attendees[0].HomeAddress.ZipCode}}", + "city": "{{meeting.Attendees[0].HomeAddress.City}}", + "country": "{{meeting.Attendees[0].HomeAddress.Country}}" + } + }, + "relationships": { + "meeting": { + "links": { + "self": "http://localhost/meetingAttendees/{{meeting.Attendees[0].StringId}}/relationships/meeting", + "related": "http://localhost/meetingAttendees/{{meeting.Attendees[0].StringId}}/meeting" + } + } + }, + "links": { + "self": "http://localhost/meetingAttendees/{{meeting.Attendees[0].StringId}}" + } + } + ], + "meta": { + "total": 1 + } } - ] - } - }, - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" - } - } - ], - ""included"": [ - { - ""type"": ""meetingAttendees"", - ""id"": """ + meeting.Attendees[0].StringId + @""", - ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", - ""homeAddress"": { - ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", - ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", - ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", - ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" - } - }, - ""relationships"": { - ""meeting"": { - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/relationships/meeting"", - ""related"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/meeting"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @""" - } - } - ], - ""meta"": { - ""total"": 1 - } -}"); + """); } [Fact] @@ -185,37 +194,39 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"?include=meeting"" - }, - ""data"": { - ""type"": ""meetingAttendees"", - ""id"": """ + attendee.StringId + @""", - ""attributes"": { - ""displayName"": """ + attendee.DisplayName + @""", - ""homeAddress"": { - ""street"": """ + attendee.HomeAddress.Street + @""", - ""zipCode"": """ + attendee.HomeAddress.ZipCode + @""", - ""city"": """ + attendee.HomeAddress.City + @""", - ""country"": """ + attendee.HomeAddress.Country + @""" - } - }, - ""relationships"": { - ""meeting"": { - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/relationships/meeting"", - ""related"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" - }, - ""data"": null - } - }, - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @""" - } - }, - ""included"": [] -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}?include=meeting" + }, + "data": { + "type": "meetingAttendees", + "id": "{{attendee.StringId}}", + "attributes": { + "displayName": {{JsonSerializer.Serialize(attendee.DisplayName, UnicodeSerializerOptions)}}, + "homeAddress": { + "street": "{{attendee.HomeAddress.Street}}", + "zipCode": "{{attendee.HomeAddress.ZipCode}}", + "city": "{{attendee.HomeAddress.City}}", + "country": "{{attendee.HomeAddress.Country}}" + } + }, + "relationships": { + "meeting": { + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}/relationships/meeting", + "related": "http://localhost/meetingAttendees/{{attendee.StringId}}/meeting" + }, + "data": null + } + }, + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}" + } + }, + "included": [] + } + """); } [Fact] @@ -239,44 +250,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings/?include=attendees"", - ""first"": ""http://localhost/meetings/?include=attendees"", - ""last"": ""http://localhost/meetings/?include=attendees"" - }, - ""data"": [ - { - ""type"": ""meetings"", - ""id"": """ + meeting.StringId + @""", - ""attributes"": { - ""title"": """ + meeting.Title + @""", - ""startTime"": """ + meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + meeting.Duration + @""", - ""location"": { - ""lat"": " + meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" - } - }, - ""relationships"": { - ""attendees"": { - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" - }, - ""data"": [] - } - }, - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" - } - } - ], - ""included"": [], - ""meta"": { - ""total"": 1 - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings/?include=attendees", + "first": "http://localhost/meetings/?include=attendees", + "last": "http://localhost/meetings/?include=attendees" + }, + "data": [ + { + "type": "meetings", + "id": "{{meeting.StringId}}", + "attributes": { + "title": "{{meeting.Title}}", + "startTime": "{{meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier)}}", + "duration": "{{meeting.Duration}}", + "location": { + "lat": {{meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture)}}, + "lng": {{meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture)}} + } + }, + "relationships": { + "attendees": { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}/relationships/attendees", + "related": "http://localhost/meetings/{{meeting.StringId}}/attendees" + }, + "data": [] + } + }, + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}" + } + } + ], + "included": [], + "meta": { + "total": 1 + } + } + """); } [Fact] @@ -299,35 +312,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" - }, - ""data"": { - ""type"": ""meetings"", - ""id"": """ + meeting.StringId + @""", - ""attributes"": { - ""title"": """ + meeting.Title + @""", - ""startTime"": """ + meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + meeting.Duration + @""", - ""location"": { - ""lat"": " + meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" - } - }, - ""relationships"": { - ""attendees"": { - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" - } - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}" + }, + "data": { + "type": "meetings", + "id": "{{meeting.StringId}}", + "attributes": { + "title": "{{meeting.Title}}", + "startTime": "{{meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier)}}", + "duration": "{{meeting.Duration}}", + "location": { + "lat": {{meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture)}}, + "lng": {{meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture)}} + } + }, + "relationships": { + "attendees": { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}/relationships/attendees", + "related": "http://localhost/meetings/{{meeting.StringId}}/attendees" + } + } + }, + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}" + } + } + } + """); } [Fact] @@ -346,19 +361,21 @@ public async Task Cannot_get_unknown_primary_resource_by_ID() string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings/ffffffff-ffff-ffff-ffff-ffffffffffff"" - }, - ""errors"": [ - { - ""id"": """ + errorId + @""", - ""status"": ""404"", - ""title"": ""The requested resource does not exist."", - ""detail"": ""Resource of type 'meetings' with ID '" + meetingId + @"' does not exist."" - } - ] -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings/ffffffff-ffff-ffff-ffff-ffffffffffff" + }, + "errors": [ + { + "id": "{{errorId}}", + "status": "404", + "title": "The requested resource does not exist.", + "detail": "Resource of type 'meetings' with ID '{{meetingId}}' does not exist." + } + ] + } + """); } [Fact] @@ -382,35 +399,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" - }, - ""data"": { - ""type"": ""meetings"", - ""id"": """ + attendee.Meeting.StringId + @""", - ""attributes"": { - ""title"": """ + attendee.Meeting.Title + @""", - ""startTime"": """ + attendee.Meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + attendee.Meeting.Duration + @""", - ""location"": { - ""lat"": " + attendee.Meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + attendee.Meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" - } - }, - ""relationships"": { - ""attendees"": { - ""links"": { - ""self"": ""http://localhost/meetings/" + attendee.Meeting.StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + attendee.Meeting.StringId + @"/attendees"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/meetings/" + attendee.Meeting.StringId + @""" - } - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}/meeting" + }, + "data": { + "type": "meetings", + "id": "{{attendee.Meeting.StringId}}", + "attributes": { + "title": "{{attendee.Meeting.Title}}", + "startTime": "{{attendee.Meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier)}}", + "duration": "{{attendee.Meeting.Duration}}", + "location": { + "lat": {{attendee.Meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture)}}, + "lng": {{attendee.Meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture)}} + } + }, + "relationships": { + "attendees": { + "links": { + "self": "http://localhost/meetings/{{attendee.Meeting.StringId}}/relationships/attendees", + "related": "http://localhost/meetings/{{attendee.Meeting.StringId}}/attendees" + } + } + }, + "links": { + "self": "http://localhost/meetings/{{attendee.Meeting.StringId}}" + } + } + } + """); } [Fact] @@ -433,12 +452,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" - }, - ""data"": null -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}/meeting" + }, + "data": null + } + """); } [Fact] @@ -462,42 +483,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", - ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", - ""last"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" - }, - ""data"": [ - { - ""type"": ""meetingAttendees"", - ""id"": """ + meeting.Attendees[0].StringId + @""", - ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", - ""homeAddress"": { - ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", - ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", - ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", - ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" - } - }, - ""relationships"": { - ""meeting"": { - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/relationships/meeting"", - ""related"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/meeting"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @""" - } - } - ], - ""meta"": { - ""total"": 1 - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}/attendees", + "first": "http://localhost/meetings/{{meeting.StringId}}/attendees", + "last": "http://localhost/meetings/{{meeting.StringId}}/attendees" + }, + "data": [ + { + "type": "meetingAttendees", + "id": "{{meeting.Attendees[0].StringId}}", + "attributes": { + "displayName": {{JsonSerializer.Serialize(meeting.Attendees[0].DisplayName, UnicodeSerializerOptions)}}, + "homeAddress": { + "street": "{{meeting.Attendees[0].HomeAddress.Street}}", + "zipCode": "{{meeting.Attendees[0].HomeAddress.ZipCode}}", + "city": "{{meeting.Attendees[0].HomeAddress.City}}", + "country": "{{meeting.Attendees[0].HomeAddress.Country}}" + } + }, + "relationships": { + "meeting": { + "links": { + "self": "http://localhost/meetingAttendees/{{meeting.Attendees[0].StringId}}/relationships/meeting", + "related": "http://localhost/meetingAttendees/{{meeting.Attendees[0].StringId}}/meeting" + } + } + }, + "links": { + "self": "http://localhost/meetingAttendees/{{meeting.Attendees[0].StringId}}" + } + } + ], + "meta": { + "total": 1 + } + } + """); } [Fact] @@ -520,16 +543,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", - ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" - }, - ""data"": [], - ""meta"": { - ""total"": 0 - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}/attendees", + "first": "http://localhost/meetings/{{meeting.StringId}}/attendees" + }, + "data": [], + "meta": { + "total": 0 + } + } + """); } [Fact] @@ -553,16 +578,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/relationships/meeting"", - ""related"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" - }, - ""data"": { - ""type"": ""meetings"", - ""id"": """ + attendee.Meeting.StringId + @""" - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}/relationships/meeting", + "related": "http://localhost/meetingAttendees/{{attendee.StringId}}/meeting" + }, + "data": { + "type": "meetings", + "id": "{{attendee.Meeting.StringId}}" + } + } + """); } [Fact] @@ -588,27 +615,29 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string[] meetingIds = [.. meeting.Attendees.Select(attendee => attendee.StringId!).OrderBy(id => id)]; - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", - ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", - ""last"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"" - }, - ""data"": [ - { - ""type"": ""meetingAttendees"", - ""id"": """ + meetingIds[0] + @""" - }, - { - ""type"": ""meetingAttendees"", - ""id"": """ + meetingIds[1] + @""" - } - ], - ""meta"": { - ""total"": 2 - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings/{{meeting.StringId}}/relationships/attendees", + "related": "http://localhost/meetings/{{meeting.StringId}}/attendees", + "first": "http://localhost/meetings/{{meeting.StringId}}/relationships/attendees", + "last": "http://localhost/meetings/{{meeting.StringId}}/relationships/attendees" + }, + "data": [ + { + "type": "meetingAttendees", + "id": "{{meetingIds[0]}}" + }, + { + "type": "meetingAttendees", + "id": "{{meetingIds[1]}}" + } + ], + "meta": { + "total": 2 + } + } + """); } [Fact] @@ -646,35 +675,37 @@ public async Task Can_create_resource_with_side_effects() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetings"" - }, - ""data"": { - ""type"": ""meetings"", - ""id"": """ + newMeeting.StringId + @""", - ""attributes"": { - ""title"": """ + newMeeting.Title + @""", - ""startTime"": """ + newMeeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + newMeeting.Duration + @""", - ""location"": { - ""lat"": " + newMeeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + newMeeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" - } - }, - ""relationships"": { - ""attendees"": { - ""links"": { - ""self"": ""http://localhost/meetings/" + newMeeting.StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + newMeeting.StringId + @"/attendees"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/meetings/" + newMeeting.StringId + @""" - } - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetings" + }, + "data": { + "type": "meetings", + "id": "{{newMeeting.StringId}}", + "attributes": { + "title": "{{newMeeting.Title}}", + "startTime": "{{newMeeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier)}}", + "duration": "{{newMeeting.Duration}}", + "location": { + "lat": {{newMeeting.Location.Latitude.ToString(CultureInfo.InvariantCulture)}}, + "lng": {{newMeeting.Location.Longitude.ToString(CultureInfo.InvariantCulture)}} + } + }, + "relationships": { + "attendees": { + "links": { + "self": "http://localhost/meetings/{{newMeeting.StringId}}/relationships/attendees", + "related": "http://localhost/meetings/{{newMeeting.StringId}}/attendees" + } + } + }, + "links": { + "self": "http://localhost/meetings/{{newMeeting.StringId}}" + } + } + } + """); } [Fact] @@ -710,35 +741,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @""" - }, - ""data"": { - ""type"": ""meetingAttendees"", - ""id"": """ + existingAttendee.StringId + @""", - ""attributes"": { - ""displayName"": """ + existingAttendee.DisplayName + @""", - ""homeAddress"": { - ""street"": """ + existingAttendee.HomeAddress.Street + @""", - ""zipCode"": """ + existingAttendee.HomeAddress.ZipCode + @""", - ""city"": """ + existingAttendee.HomeAddress.City + @""", - ""country"": """ + existingAttendee.HomeAddress.Country + @""" - } - }, - ""relationships"": { - ""meeting"": { - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @"/relationships/meeting"", - ""related"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @"/meeting"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @""" - } - } -}"); + responseDocument.Should().BeJson($$""" + { + "links": { + "self": "http://localhost/meetingAttendees/{{existingAttendee.StringId}}" + }, + "data": { + "type": "meetingAttendees", + "id": "{{existingAttendee.StringId}}", + "attributes": { + "displayName": {{JsonSerializer.Serialize(existingAttendee.DisplayName, UnicodeSerializerOptions)}}, + "homeAddress": { + "street": "{{existingAttendee.HomeAddress.Street}}", + "zipCode": "{{existingAttendee.HomeAddress.ZipCode}}", + "city": "{{existingAttendee.HomeAddress.City}}", + "country": "{{existingAttendee.HomeAddress.Country}}" + } + }, + "relationships": { + "meeting": { + "links": { + "self": "http://localhost/meetingAttendees/{{existingAttendee.StringId}}/relationships/meeting", + "related": "http://localhost/meetingAttendees/{{existingAttendee.StringId}}/meeting" + } + } + }, + "links": { + "self": "http://localhost/meetingAttendees/{{existingAttendee.StringId}}" + } + } + } + """); } [Fact] @@ -810,15 +843,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"" - }, - ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" - }, - ""data"": null -}"); + responseDocument.Should().BeJson($$""" + { + "jsonapi": { + "version": "1.1" + }, + "links": { + "self": "http://localhost/meetingAttendees/{{attendee.StringId}}/meeting" + }, + "data": null + } + """); } [Fact] @@ -840,21 +875,23 @@ public async Task Includes_version_on_error_at_resource_endpoint() string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"" - }, - ""links"": { - ""self"": ""http://localhost/meetingAttendees/ffffffff-ffff-ffff-ffff-ffffffffffff"" - }, - ""errors"": [ - { - ""id"": """ + errorId + @""", - ""status"": ""404"", - ""title"": ""The requested resource does not exist."", - ""detail"": ""Resource of type 'meetingAttendees' with ID '" + attendeeId + @"' does not exist."" - } - ] -}"); + responseDocument.Should().BeJson($$""" + { + "jsonapi": { + "version": "1.1" + }, + "links": { + "self": "http://localhost/meetingAttendees/ffffffff-ffff-ffff-ffff-ffffffffffff" + }, + "errors": [ + { + "id": "{{errorId}}", + "status": "404", + "title": "The requested resource does not exist.", + "detail": "Resource of type 'meetingAttendees' with ID '{{attendeeId}}' does not exist." + } + ] + } + """); } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs index e5c97abdb5..2d2e07e600 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs @@ -30,121 +30,130 @@ public void Resources_in_deeply_nested_circular_chain_are_written_in_relationshi article.Author.FavoriteFood = fakers.Food.Generate(); article.Author.Blogs.ElementAt(1).Reviewer.FavoriteFood = fakers.Food.Generate(); - IJsonApiOptions options = new JsonApiOptions(); + IJsonApiOptions options = new JsonApiOptions + { + SerializerOptions = + { + WriteIndented = true + } + }; + ResponseModelAdapter responseModelAdapter = CreateAdapter(options, article.StringId, "author.blogs.reviewer.favoriteFood"); // Act Document document = responseModelAdapter.Convert(article); // Assert - string text = JsonSerializer.Serialize(document, new JsonSerializerOptions(options.SerializerWriteOptions)); + string text = JsonSerializer.Serialize(document, options.SerializerWriteOptions); // ReSharper disable StringLiteralTypo - text.Should().BeJson(@"{ - ""data"": { - ""type"": ""articles"", - ""id"": ""1"", - ""attributes"": { - ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" - }, - ""relationships"": { - ""author"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - } - } - }, - ""included"": [ - { - ""type"": ""people"", - ""id"": ""2"", - ""attributes"": { - ""name"": ""Ernestine Runte"" - }, - ""relationships"": { - ""blogs"": { - ""data"": [ - { - ""type"": ""blogs"", - ""id"": ""3"" - }, + text.Should().BeJson(""" { - ""type"": ""blogs"", - ""id"": ""4"" + "data": { + "type": "articles", + "id": "1", + "attributes": { + "title": "The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!" + }, + "relationships": { + "author": { + "data": { + "type": "people", + "id": "2" + } + } + } + }, + "included": [ + { + "type": "people", + "id": "2", + "attributes": { + "name": "Ernestine Runte" + }, + "relationships": { + "blogs": { + "data": [ + { + "type": "blogs", + "id": "3" + }, + { + "type": "blogs", + "id": "4" + } + ] + }, + "favoriteFood": { + "data": { + "type": "foods", + "id": "6" + } + } + } + }, + { + "type": "blogs", + "id": "3", + "attributes": { + "title": "The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "2" + } + } + } + }, + { + "type": "blogs", + "id": "4", + "attributes": { + "title": "I'll connect the mobile ADP card, that should card the ADP card!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "5" + } + } + } + }, + { + "type": "people", + "id": "5", + "attributes": { + "name": "Doug Shields" + }, + "relationships": { + "favoriteFood": { + "data": { + "type": "foods", + "id": "7" + } + } + } + }, + { + "type": "foods", + "id": "7", + "attributes": { + "dish": "Nostrum totam harum totam voluptatibus." + } + }, + { + "type": "foods", + "id": "6", + "attributes": { + "dish": "Illum assumenda iste quia natus et dignissimos reiciendis." + } + } + ] } - ] - }, - ""favoriteFood"": { - ""data"": { - ""type"": ""foods"", - ""id"": ""6"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""3"", - ""attributes"": { - ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""4"", - ""attributes"": { - ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""5"" - } - } - } - }, - { - ""type"": ""people"", - ""id"": ""5"", - ""attributes"": { - ""name"": ""Doug Shields"" - }, - ""relationships"": { - ""favoriteFood"": { - ""data"": { - ""type"": ""foods"", - ""id"": ""7"" - } - } - } - }, - { - ""type"": ""foods"", - ""id"": ""7"", - ""attributes"": { - ""dish"": ""Nostrum totam harum totam voluptatibus."" - } - }, - { - ""type"": ""foods"", - ""id"": ""6"", - ""attributes"": { - ""dish"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" - } - } - ] -}"); + """); // ReSharper restore StringLiteralTypo } @@ -165,7 +174,14 @@ public void Resources_in_deeply_nested_circular_chains_are_written_in_relationsh Article article2 = fakers.Article.Generate(); article2.Author = article1.Author; - IJsonApiOptions options = new JsonApiOptions(); + IJsonApiOptions options = new JsonApiOptions + { + SerializerOptions = + { + WriteIndented = true + } + }; + ResponseModelAdapter responseModelAdapter = CreateAdapter(options, article1.StringId, "author.blogs.reviewer.favoriteFood"); // Act @@ -176,131 +192,133 @@ public void Resources_in_deeply_nested_circular_chains_are_written_in_relationsh }); // Assert - string text = JsonSerializer.Serialize(document, new JsonSerializerOptions(options.SerializerWriteOptions)); + string text = JsonSerializer.Serialize(document, options.SerializerWriteOptions); // ReSharper disable StringLiteralTypo - text.Should().BeJson(@"{ - ""data"": [ - { - ""type"": ""articles"", - ""id"": ""1"", - ""attributes"": { - ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" - }, - ""relationships"": { - ""author"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - } - } - }, - { - ""type"": ""articles"", - ""id"": ""8"", - ""attributes"": { - ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" - }, - ""relationships"": { - ""author"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - } - } - } - ], - ""included"": [ - { - ""type"": ""people"", - ""id"": ""2"", - ""attributes"": { - ""name"": ""Ernestine Runte"" - }, - ""relationships"": { - ""blogs"": { - ""data"": [ - { - ""type"": ""blogs"", - ""id"": ""3"" - }, + text.Should().BeJson(""" { - ""type"": ""blogs"", - ""id"": ""4"" + "data": [ + { + "type": "articles", + "id": "1", + "attributes": { + "title": "The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!" + }, + "relationships": { + "author": { + "data": { + "type": "people", + "id": "2" + } + } + } + }, + { + "type": "articles", + "id": "8", + "attributes": { + "title": "I'll connect the mobile ADP card, that should card the ADP card!" + }, + "relationships": { + "author": { + "data": { + "type": "people", + "id": "2" + } + } + } + } + ], + "included": [ + { + "type": "people", + "id": "2", + "attributes": { + "name": "Ernestine Runte" + }, + "relationships": { + "blogs": { + "data": [ + { + "type": "blogs", + "id": "3" + }, + { + "type": "blogs", + "id": "4" + } + ] + }, + "favoriteFood": { + "data": { + "type": "foods", + "id": "6" + } + } + } + }, + { + "type": "blogs", + "id": "3", + "attributes": { + "title": "The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "2" + } + } + } + }, + { + "type": "blogs", + "id": "4", + "attributes": { + "title": "I'll connect the mobile ADP card, that should card the ADP card!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "5" + } + } + } + }, + { + "type": "people", + "id": "5", + "attributes": { + "name": "Doug Shields" + }, + "relationships": { + "favoriteFood": { + "data": { + "type": "foods", + "id": "7" + } + } + } + }, + { + "type": "foods", + "id": "7", + "attributes": { + "dish": "Nostrum totam harum totam voluptatibus." + } + }, + { + "type": "foods", + "id": "6", + "attributes": { + "dish": "Illum assumenda iste quia natus et dignissimos reiciendis." + } + } + ] } - ] - }, - ""favoriteFood"": { - ""data"": { - ""type"": ""foods"", - ""id"": ""6"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""3"", - ""attributes"": { - ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""4"", - ""attributes"": { - ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""5"" - } - } - } - }, - { - ""type"": ""people"", - ""id"": ""5"", - ""attributes"": { - ""name"": ""Doug Shields"" - }, - ""relationships"": { - ""favoriteFood"": { - ""data"": { - ""type"": ""foods"", - ""id"": ""7"" - } - } - } - }, - { - ""type"": ""foods"", - ""id"": ""7"", - ""attributes"": { - ""dish"": ""Nostrum totam harum totam voluptatibus."" - } - }, - { - ""type"": ""foods"", - ""id"": ""6"", - ""attributes"": { - ""dish"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" - } - } - ] -}"); + """); // ReSharper restore StringLiteralTypo } @@ -327,7 +345,13 @@ public void Resources_in_overlapping_deeply_nested_circular_chains_are_written_i article.Author.Blogs.ElementAt(1).Reviewer.FavoriteSong = fakers.Song.Generate(); article.Reviewer.FavoriteSong = fakers.Song.Generate(); - IJsonApiOptions options = new JsonApiOptions(); + IJsonApiOptions options = new JsonApiOptions + { + SerializerOptions = + { + WriteIndented = true + } + }; ResponseModelAdapter responseModelAdapter = CreateAdapter(options, article.StringId, "author.blogs.reviewer.favoriteFood,reviewer.blogs.author.favoriteSong"); @@ -336,188 +360,190 @@ public void Resources_in_overlapping_deeply_nested_circular_chains_are_written_i Document document = responseModelAdapter.Convert(article); // Assert - string text = JsonSerializer.Serialize(document, new JsonSerializerOptions(options.SerializerWriteOptions)); + string text = JsonSerializer.Serialize(document, options.SerializerWriteOptions); // ReSharper disable StringLiteralTypo - text.Should().BeJson(@"{ - ""data"": { - ""type"": ""articles"", - ""id"": ""1"", - ""attributes"": { - ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""8"" - } - }, - ""author"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - } - } - }, - ""included"": [ - { - ""type"": ""people"", - ""id"": ""8"", - ""attributes"": { - ""name"": ""Nettie Howell"" - }, - ""relationships"": { - ""blogs"": { - ""data"": [ - { - ""type"": ""blogs"", - ""id"": ""9"" - }, + text.Should().BeJson(""" { - ""type"": ""blogs"", - ""id"": ""3"" + "data": { + "type": "articles", + "id": "1", + "attributes": { + "title": "The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "8" + } + }, + "author": { + "data": { + "type": "people", + "id": "2" + } + } + } + }, + "included": [ + { + "type": "people", + "id": "8", + "attributes": { + "name": "Nettie Howell" + }, + "relationships": { + "blogs": { + "data": [ + { + "type": "blogs", + "id": "9" + }, + { + "type": "blogs", + "id": "3" + } + ] + }, + "favoriteSong": { + "data": { + "type": "songs", + "id": "11" + } + } + } + }, + { + "type": "blogs", + "id": "9", + "attributes": { + "title": "The RSS bus is down, parse the mobile bus so we can parse the RSS bus!" + }, + "relationships": { + "author": { + "data": { + "type": "people", + "id": "8" + } + } + } + }, + { + "type": "blogs", + "id": "3", + "attributes": { + "title": "The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "2" + } + }, + "author": { + "data": { + "type": "people", + "id": "5" + } + } + } + }, + { + "type": "people", + "id": "2", + "attributes": { + "name": "Ernestine Runte" + }, + "relationships": { + "blogs": { + "data": [ + { + "type": "blogs", + "id": "3" + }, + { + "type": "blogs", + "id": "4" + } + ] + }, + "favoriteFood": { + "data": { + "type": "foods", + "id": "6" + } + } + } + }, + { + "type": "blogs", + "id": "4", + "attributes": { + "title": "I'll connect the mobile ADP card, that should card the ADP card!" + }, + "relationships": { + "reviewer": { + "data": { + "type": "people", + "id": "5" + } + } + } + }, + { + "type": "people", + "id": "5", + "attributes": { + "name": "Doug Shields" + }, + "relationships": { + "favoriteFood": { + "data": { + "type": "foods", + "id": "7" + } + }, + "favoriteSong": { + "data": { + "type": "songs", + "id": "10" + } + } + } + }, + { + "type": "foods", + "id": "7", + "attributes": { + "dish": "Nostrum totam harum totam voluptatibus." + } + }, + { + "type": "songs", + "id": "10", + "attributes": { + "title": "Illum assumenda iste quia natus et dignissimos reiciendis." + } + }, + { + "type": "foods", + "id": "6", + "attributes": { + "dish": "Illum assumenda iste quia natus et dignissimos reiciendis." + } + }, + { + "type": "songs", + "id": "11", + "attributes": { + "title": "Nostrum totam harum totam voluptatibus." + } + } + ] } - ] - }, - ""favoriteSong"": { - ""data"": { - ""type"": ""songs"", - ""id"": ""11"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""9"", - ""attributes"": { - ""title"": ""The RSS bus is down, parse the mobile bus so we can parse the RSS bus!"" - }, - ""relationships"": { - ""author"": { - ""data"": { - ""type"": ""people"", - ""id"": ""8"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""3"", - ""attributes"": { - ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""2"" - } - }, - ""author"": { - ""data"": { - ""type"": ""people"", - ""id"": ""5"" - } - } - } - }, - { - ""type"": ""people"", - ""id"": ""2"", - ""attributes"": { - ""name"": ""Ernestine Runte"" - }, - ""relationships"": { - ""blogs"": { - ""data"": [ - { - ""type"": ""blogs"", - ""id"": ""3"" - }, - { - ""type"": ""blogs"", - ""id"": ""4"" - } - ] - }, - ""favoriteFood"": { - ""data"": { - ""type"": ""foods"", - ""id"": ""6"" - } - } - } - }, - { - ""type"": ""blogs"", - ""id"": ""4"", - ""attributes"": { - ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" - }, - ""relationships"": { - ""reviewer"": { - ""data"": { - ""type"": ""people"", - ""id"": ""5"" - } - } - } - }, - { - ""type"": ""people"", - ""id"": ""5"", - ""attributes"": { - ""name"": ""Doug Shields"" - }, - ""relationships"": { - ""favoriteFood"": { - ""data"": { - ""type"": ""foods"", - ""id"": ""7"" - } - }, - ""favoriteSong"": { - ""data"": { - ""type"": ""songs"", - ""id"": ""10"" - } - } - } - }, - { - ""type"": ""foods"", - ""id"": ""7"", - ""attributes"": { - ""dish"": ""Nostrum totam harum totam voluptatibus."" - } - }, - { - ""type"": ""songs"", - ""id"": ""10"", - ""attributes"": { - ""title"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" - } - }, - { - ""type"": ""foods"", - ""id"": ""6"", - ""attributes"": { - ""dish"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" - } - }, - { - ""type"": ""songs"", - ""id"": ""11"", - ""attributes"": { - ""title"": ""Nostrum totam harum totam voluptatibus."" - } - } - ] -}"); + """); // ReSharper restore StringLiteralTypo } diff --git a/test/SourceGeneratorTests/ControllerGenerationTests.cs b/test/SourceGeneratorTests/ControllerGenerationTests.cs index c1d56d12dd..8f24b882a0 100644 --- a/test/SourceGeneratorTests/ControllerGenerationTests.cs +++ b/test/SourceGeneratorTests/ControllerGenerationTests.cs @@ -24,13 +24,14 @@ public void Can_generate_for_default_controller() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -51,25 +52,27 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using ExampleApi.Models; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using ExampleApi.Models; -namespace ExampleApi.Controllers; + namespace ExampleApi.Controllers; -public sealed partial class ItemsController : JsonApiController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); } [Fact] @@ -86,13 +89,14 @@ public void Can_generate_for_read_only_controller() .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -113,25 +117,27 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using ExampleApi.Models; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using ExampleApi.Models; -namespace ExampleApi.Controllers; + namespace ExampleApi.Controllers; -public sealed partial class ItemsController : JsonApiQueryController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceQueryService queryService) - : base(options, resourceGraph, loggerFactory, queryService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiQueryController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService queryService) + : base(options, resourceGraph, loggerFactory, queryService) + { + } + } + + """); } [Fact] @@ -148,13 +154,14 @@ public void Can_generate_for_write_only_controller() .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Command)] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -175,25 +182,27 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using ExampleApi.Models; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using ExampleApi.Models; -namespace ExampleApi.Controllers; + namespace ExampleApi.Controllers; -public sealed partial class ItemsController : JsonApiCommandController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceCommandService commandService) - : base(options, resourceGraph, loggerFactory, commandService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiCommandController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService commandService) + : base(options, resourceGraph, loggerFactory, commandService) + { + } + } + + """); } [Fact] @@ -210,16 +219,17 @@ public void Can_generate_for_mixed_controller() .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource(GenerateControllerEndpoints = NoRelationshipEndpoints)] public sealed class Item : Identifiable { private const JsonApiEndpoints NoRelationshipEndpoints = JsonApiEndpoints.GetCollection | JsonApiEndpoints.GetSingle | JsonApiEndpoints.Post | JsonApiEndpoints.Patch | JsonApiEndpoints.Delete; - + [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -240,34 +250,36 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// - -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using ExampleApi.Models; - -namespace ExampleApi.Controllers; + runResult.Should().HaveProducedSourceCode(""" + // + + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using ExampleApi.Models; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService getAll, + IGetByIdService getById, + ICreateService create, + IUpdateService update, + IDeleteService delete) + : base(options, resourceGraph, loggerFactory, + getAll: getAll, + getById: getById, + create: create, + update: update, + delete: delete) + { + } + } -public sealed partial class ItemsController : JsonApiController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IGetAllService getAll, - IGetByIdService getById, - ICreateService create, - IUpdateService update, - IDeleteService delete) - : base(options, resourceGraph, loggerFactory, - getAll: getAll, - getById: getById, - create: create, - update: update, - delete: delete) - { - } -} -"); + """); } [Fact] @@ -283,12 +295,13 @@ public void Skips_for_resource_without_ResourceAttribute() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(AttrAttribute)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -325,13 +338,14 @@ public void Skips_for_resource_with_no_endpoints() .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -365,7 +379,7 @@ public void Skips_for_missing_dependency_on_JsonApiDotNetCore() string source = new SourceCodeBuilder() .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" public abstract class Identifiable { } @@ -383,7 +397,8 @@ public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -421,13 +436,14 @@ public void Skips_for_missing_dependency_on_LoggerFactory() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -464,13 +480,14 @@ public void Warns_for_resource_that_does_not_implement_IIdentifiable() string source = new SourceCodeBuilder() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" [Resource] public sealed class Item { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -491,7 +508,7 @@ public sealed class Item GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().HaveSingleDiagnostic( - "(6,17): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); + "(5,1): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); runResult.Should().NotHaveProducedSourceCode(); } @@ -509,7 +526,7 @@ public void Adds_nullable_enable_for_nullable_reference_ID_type() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") - .WithCode(@" + .WithCode(""" #nullable enable [Resource] @@ -517,7 +534,8 @@ public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -554,13 +572,14 @@ public void Can_generate_for_custom_namespace() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") - .WithCode(@" - [Resource(ControllerNamespace = ""Some.Path.To.Generate.Code.In"")] + .WithCode(""" + [Resource(ControllerNamespace = "Some.Path.To.Generate.Code.In")] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -581,25 +600,27 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using ExampleApi.Models; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using ExampleApi.Models; -namespace Some.Path.To.Generate.Code.In; + namespace Some.Path.To.Generate.Code.In; -public sealed partial class ItemsController : JsonApiController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); } [Fact] @@ -615,13 +636,14 @@ public void Can_generate_for_top_level_namespace() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("TopLevel") - .WithCode(@" + .WithCode(""" [Resource] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -642,25 +664,27 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using TopLevel; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using TopLevel; -namespace Controllers; + namespace Controllers; -public sealed partial class ItemsController : JsonApiController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); } [Fact] @@ -675,13 +699,14 @@ public void Can_generate_for_global_namespace() string source = new SourceCodeBuilder() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) - .WithCode(@" + .WithCode(""" [Resource] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -702,22 +727,24 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; -public sealed partial class ItemsController : JsonApiController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); } [Fact] @@ -733,13 +760,14 @@ public void Can_generate_for_shared_namespace() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi") - .WithCode(@" - [Resource(ControllerNamespace = ""ExampleApi"")] + .WithCode(""" + [Resource(ControllerNamespace = "ExampleApi")] public sealed class Item : Identifiable { [Attr] public int Value { get; set; } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -760,24 +788,26 @@ public sealed class Item : Identifiable GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCode(@"// + runResult.Should().HaveProducedSourceCode(""" + // -using Microsoft.Extensions.Logging; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; -namespace ExampleApi; + namespace ExampleApi; -public sealed partial class ItemsController : JsonApiController -{ - public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} -"); + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); } [Fact] @@ -792,7 +822,7 @@ public void Generates_unique_file_names_for_duplicate_resource_name_in_different string source = new SourceCodeBuilder() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) - .WithCode(@" + .WithCode(""" namespace The.First.One { [Resource] @@ -811,7 +841,8 @@ public sealed class Item : Identifiable [Attr] public int Value { get; set; } } - }") + } + """) .Build(); Compilation inputCompilation = new CompilationBuilder() From 2aecb1755735069c3447c8a9eca814c63aecf203 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:26:54 +0100 Subject: [PATCH 43/53] Resharper: convert to switch expression --- .../CollectionConverter.cs | 33 +++++-------------- .../QueryableBuilding/WhereClauseBuilder.cs | 30 ++++++----------- 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index 6edce84335..645b5329b7 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -68,32 +68,15 @@ private Type ToConcreteCollectionType(Type collectionType) /// public IReadOnlyCollection ExtractResources(object? value) { - if (value is List resourceList) + return value switch { - return resourceList; - } - - if (value is HashSet resourceSet) - { - return resourceSet; - } - - if (value is IReadOnlyCollection resourceCollection) - { - return resourceCollection; - } - - if (value is IEnumerable resources) - { - return resources.ToList(); - } - - if (value is IIdentifiable resource) - { - return resource.AsArray(); - } - - return Array.Empty(); + List resourceList => resourceList, + HashSet resourceSet => resourceSet, + IReadOnlyCollection resourceCollection => resourceCollection, + IEnumerable resources => resources.ToList(), + IIdentifiable resource => resource.AsArray(), + _ => Array.Empty() + }; } /// diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index 981b2da1a3..fa8e69d8a5 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -100,17 +100,12 @@ public override Expression VisitMatchText(MatchTextExpression expression, QueryC Expression text = Visit(expression.TextValue, context); - if (expression.MatchKind == TextMatchKind.StartsWith) + return expression.MatchKind switch { - return Expression.Call(property, "StartsWith", null, text); - } - - if (expression.MatchKind == TextMatchKind.EndsWith) - { - return Expression.Call(property, "EndsWith", null, text); - } - - return Expression.Call(property, "Contains", null, text); + TextMatchKind.StartsWith => Expression.Call(property, "StartsWith", null, text), + TextMatchKind.EndsWith => Expression.Call(property, "EndsWith", null, text), + _ => Expression.Call(property, "Contains", null, text) + }; } public override Expression VisitAny(AnyExpression expression, QueryClauseBuilderContext context) @@ -137,17 +132,12 @@ public override Expression VisitLogical(LogicalExpression expression, QueryClaus { var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, context))); - if (expression.Operator == LogicalOperator.And) - { - return Compose(termQueue, Expression.AndAlso); - } - - if (expression.Operator == LogicalOperator.Or) + return expression.Operator switch { - return Compose(termQueue, Expression.OrElse); - } - - throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'."); + LogicalOperator.And => Compose(termQueue, Expression.AndAlso), + LogicalOperator.Or => Compose(termQueue, Expression.OrElse), + _ => throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'.") + }; } private static BinaryExpression Compose(Queue argumentQueue, Func applyOperator) From 1c2ba5ab538ab5f30ce4b58ed9ee9d3efc8b6343 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 21 Nov 2023 17:35:19 +0100 Subject: [PATCH 44/53] Update to Resharper 2023.3-EAP8 (required to enable analysis on .NET 8 RTM) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6dc2459ae5..6491f7d142 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2023.2.3", + "version": "2023.3.0-eap08", "commands": [ "jb" ] From 8ef0987e5abe7918fa2229ad0d39ea9fc82ca84e Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:17:12 +0100 Subject: [PATCH 45/53] Add workaround for Resharper bug "Property can be made init-only" --- .../Configuration/DependencyContainerRegistrationTests.cs | 3 +++ .../FieldChains/FieldChainPatternInheritanceMatchTests.cs | 3 +++ .../UnitTests/FieldChains/FieldChainPatternMatchTests.cs | 3 +++ .../UnitTests/Middleware/JsonApiMiddlewareTests.cs | 3 +++ .../ModelStateValidation/ModelStateValidationTests.cs | 3 +++ .../ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs | 3 +++ .../UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs | 3 +++ .../Serialization/Response/IncompleteResourceGraphTests.cs | 3 +++ test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs | 3 +++ 9 files changed, 27 insertions(+) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs index 0289585dfb..9e96bc718a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs @@ -15,6 +15,9 @@ using TestBuildingBlocks; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + namespace JsonApiDotNetCoreTests.UnitTests.Configuration; public sealed class DependencyContainerRegistrationTests diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index 6036bfc6d4..fece0100f8 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs @@ -11,6 +11,9 @@ using Xunit; using Xunit.Abstractions; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + // ReSharper disable InconsistentNaming #pragma warning disable AV1706 // Identifier contains an abbreviation or is too short diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 052ec4a44f..cfffac4e2c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -11,6 +11,9 @@ using Xunit; using Xunit.Abstractions; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + // ReSharper disable InconsistentNaming #pragma warning disable AV1706 // Identifier contains an abbreviation or is too short diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs index d11eb53a19..4293a894df 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -13,6 +13,9 @@ using TestBuildingBlocks; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + #pragma warning disable AV1561 // Signature contains too many parameters namespace JsonApiDotNetCoreTests.UnitTests.Middleware; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs index 34bbc16d17..a1daade80a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs @@ -10,6 +10,9 @@ using TestBuildingBlocks; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + namespace JsonApiDotNetCoreTests.UnitTests.ModelStateValidation; public sealed class ModelStateValidationTests diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs index 337940b75c..bedb5398f8 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceDefinitions/CreateSortExpressionFromLambdaTests.cs @@ -11,6 +11,9 @@ using TestBuildingBlocks; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + namespace JsonApiDotNetCoreTests.UnitTests.ResourceDefinitions; public sealed class CreateSortExpressionFromLambdaTests diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index ebce906b9d..0f270ddfeb 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -10,6 +10,9 @@ using TestBuildingBlocks; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; public sealed class ResourceGraphBuilderTests diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs index 85329874ea..5d6551e2ad 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs @@ -10,6 +10,9 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response; public sealed class IncompleteResourceGraphTests diff --git a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs index e8fe1585ca..27a52c0732 100644 --- a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs +++ b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs @@ -6,6 +6,9 @@ using NoEntityFrameworkExample; using Xunit; +// Workaround for Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494909/Breaking-UsedImplicitly-and-PublicAPI-on-types-no-longer-respected. +// ReSharper disable PropertyCanBeMadeInitOnly.Local + namespace NoEntityFrameworkTests; public sealed class NullSafeExpressionRewriterTests From f1194d16bf089a000ef8c1c4418d7bd8138f5147 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 21 Nov 2023 21:42:23 +0100 Subject: [PATCH 46/53] Add workaround for Resharper cleanup bug --- test/UnitTests/Internal/ErrorObjectTests.cs | 27 ++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/test/UnitTests/Internal/ErrorObjectTests.cs b/test/UnitTests/Internal/ErrorObjectTests.cs index 7deea9831c..bb8c4e3bcc 100644 --- a/test/UnitTests/Internal/ErrorObjectTests.cs +++ b/test/UnitTests/Internal/ErrorObjectTests.cs @@ -7,13 +7,28 @@ namespace UnitTests.Internal; public sealed class ErrorObjectTests { - // @formatter:wrap_array_initializer_style wrap_if_long + // Formatting below is broken due to Resharper bug at https://youtrack.jetbrains.com/issue/RSRP-494897/Formatter-directive-broken-in-2023.3-EAP7. + // This no longer works: @formatter:wrap_array_initializer_style wrap_if_long [Theory] - [InlineData(new[] { HttpStatusCode.UnprocessableEntity }, HttpStatusCode.UnprocessableEntity)] - [InlineData(new[] { HttpStatusCode.UnprocessableEntity, HttpStatusCode.UnprocessableEntity }, HttpStatusCode.UnprocessableEntity)] - [InlineData(new[] { HttpStatusCode.UnprocessableEntity, HttpStatusCode.Unauthorized }, HttpStatusCode.BadRequest)] - [InlineData(new[] { HttpStatusCode.UnprocessableEntity, HttpStatusCode.BadGateway }, HttpStatusCode.InternalServerError)] - // @formatter:wrap_array_initializer_style restore + [InlineData(new[] + { + HttpStatusCode.UnprocessableEntity + }, HttpStatusCode.UnprocessableEntity)] + [InlineData(new[] + { + HttpStatusCode.UnprocessableEntity, + HttpStatusCode.UnprocessableEntity + }, HttpStatusCode.UnprocessableEntity)] + [InlineData(new[] + { + HttpStatusCode.UnprocessableEntity, + HttpStatusCode.Unauthorized + }, HttpStatusCode.BadRequest)] + [InlineData(new[] + { + HttpStatusCode.UnprocessableEntity, + HttpStatusCode.BadGateway + }, HttpStatusCode.InternalServerError)] public void ErrorDocument_GetErrorStatusCode_IsCorrect(HttpStatusCode[] errorCodes, HttpStatusCode expected) { // Arrange From aeae31bce2c697988a0247a52b03ac322460eb12 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 02:47:03 +0000 Subject: [PATCH 47/53] Bump docfx from 2.72.1 to 2.74.0 Bumps [docfx](https://github.com/dotnet/docfx) from 2.72.1 to 2.74.0. - [Release notes](https://github.com/dotnet/docfx/releases) - [Changelog](https://github.com/dotnet/docfx/blob/main/RELEASENOTE.md) - [Commits](https://github.com/dotnet/docfx/compare/v2.72.1...v2.74.0) --- updated-dependencies: - dependency-name: docfx dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6491f7d142..c8c97435a8 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "docfx": { - "version": "2.72.1", + "version": "2.74.0", "commands": [ "docfx" ] From 215df7483ddb6c6bb73ecc59f5412d21612b1b6a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:38:50 +0100 Subject: [PATCH 48/53] Remove ArrayFactory.Create --- .../QueryString/QueryStringParserBenchmarks.cs | 11 ++++++++--- src/JsonApiDotNetCore/ArrayFactory.cs | 12 ------------ .../Configuration/ServiceDiscoveryFacade.cs | 8 +++++--- .../Errors/InvalidModelStateException.cs | 7 ++++++- .../Queries/QueryableBuilding/OrderClauseBuilder.cs | 7 ++++++- .../Queries/QueryableBuilding/SelectClauseBuilder.cs | 7 ++++++- src/JsonApiDotNetCore/Resources/ResourceFactory.cs | 2 +- .../Serialization/Response/ETagGenerator.cs | 8 +++++++- .../Relationships/ReplaceToManyRelationshipTests.cs | 3 +-- .../Resources/ReplaceToManyRelationshipTests.cs | 2 +- .../ResourceTypeCapturingDefinition.cs | 2 +- 11 files changed, 42 insertions(+), 27 deletions(-) delete mode 100644 src/JsonApiDotNetCore/ArrayFactory.cs diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index 2f466a3fcb..0b2f88134a 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -1,7 +1,6 @@ using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using Benchmarks.Tools; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Parsing; @@ -55,8 +54,14 @@ public QueryStringParserBenchmarks() var paginationParser = new PaginationParser(); var paginationReader = new PaginationQueryStringParameterReader(paginationParser, request, resourceGraph, options); - IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, - sparseFieldSetReader, paginationReader); + IQueryStringParameterReader[] readers = + [ + includeReader, + filterReader, + sortReader, + sparseFieldSetReader, + paginationReader + ]; _queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance); } diff --git a/src/JsonApiDotNetCore/ArrayFactory.cs b/src/JsonApiDotNetCore/ArrayFactory.cs deleted file mode 100644 index 6ad678c64d..0000000000 --- a/src/JsonApiDotNetCore/ArrayFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection - -namespace JsonApiDotNetCore; - -internal static class ArrayFactory -{ - public static T[] Create(params T[] items) - { - return items; - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 5d2ac7190b..01f9ecc7cb 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -152,9 +152,11 @@ private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resour private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) { - Type[] typeArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 - ? ArrayFactory.Create(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType) - : ArrayFactory.Create(resourceDescriptor.ResourceClrType); + Type[] typeArguments = + [ + resourceDescriptor.ResourceClrType, + resourceDescriptor.IdClrType + ]; (Type implementationType, Type serviceInterface)? result = _typeLocator.GetContainerRegistrationFromAssembly(assembly, interfaceType, typeArguments); diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index b5805e15dc..ce8ab8a1b0 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -207,7 +207,12 @@ private abstract class ModelStateKeySegment private const char Dot = '.'; private const char BracketOpen = '['; private const char BracketClose = ']'; - private static readonly char[] KeySegmentStartTokens = ArrayFactory.Create(Dot, BracketOpen); + + private static readonly char[] KeySegmentStartTokens = + [ + Dot, + BracketOpen + ]; // The right part of the full key, which nested segments are produced from. private readonly string _nextKey; diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs index 09f0c5326e..93dd7c53d1 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs @@ -57,7 +57,12 @@ private static string GetOperationName(bool isAscending, QueryClauseBuilderConte private static Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector, QueryClauseBuilderContext context) { - Type[] typeArguments = ArrayFactory.Create(context.LambdaScope.Parameter.Type, keyType); + Type[] typeArguments = + [ + context.LambdaScope.Parameter.Type, + keyType + ]; + return Expression.Call(context.ExtensionType, operationName, typeArguments, source, keySelector); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 3f23013d7c..43d82d77ce 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -247,7 +247,12 @@ private static Expression CopyCollectionExtensionMethodCall(Expression source, s private static Expression SelectExtensionMethodCall(Type extensionType, Expression source, Type elementType, Expression selectBody) { - Type[] typeArguments = ArrayFactory.Create(elementType, elementType); + Type[] typeArguments = + [ + elementType, + elementType + ]; + return Expression.Call(extensionType, "Select", typeArguments, source, selectBody); } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index b803544bec..9d282b4938 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -50,7 +50,7 @@ private static IIdentifiable CreateWrapperForAbstractType(Type resourceClrType) Type wrapperClrType = typeof(AbstractResourceWrapper<>).MakeGenericType(descriptor.IdClrType); ConstructorInfo constructor = wrapperClrType.GetConstructors().Single(); - object resource = constructor.Invoke(ArrayFactory.Create(resourceClrType)); + object resource = constructor.Invoke([resourceClrType]); return (IIdentifiable)resource; } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs index 7a22cf5a60..d5aefb1fee 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs @@ -17,7 +17,13 @@ public ETagGenerator(IFingerprintGenerator fingerprintGenerator) /// public EntityTagHeaderValue Generate(string requestUrl, string responseBody) { - string fingerprint = _fingerprintGenerator.Generate(ArrayFactory.Create(requestUrl, responseBody)); + string[] elements = + [ + requestUrl, + responseBody + ]; + + string fingerprint = _fingerprintGenerator.Generate(elements); string eTagValue = $"\"{fingerprint}\""; return EntityTagHeaderValue.Parse(eTagValue); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index d3aa61afaf..431bf89396 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -1,6 +1,5 @@ using System.Net; using FluentAssertions; -using JsonApiDotNetCore; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; @@ -880,7 +879,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => dbContext.WorkItems.Add(existingWorkItem); await dbContext.SaveChangesAsync(); - existingWorkItem.RelatedFrom = ArrayFactory.Create(existingWorkItem); + existingWorkItem.RelatedFrom = [existingWorkItem]; await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index 0cee60ef86..9b9f96ddce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -965,7 +965,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => dbContext.WorkItems.Add(existingWorkItem); await dbContext.SaveChangesAsync(); - existingWorkItem.RelatedFrom = ArrayFactory.Create(existingWorkItem); + existingWorkItem.RelatedFrom = [existingWorkItem]; await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceTypeCapturingDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceTypeCapturingDefinition.cs index 3b92b278da..e2754968a7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceTypeCapturingDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceTypeCapturingDefinition.cs @@ -82,7 +82,7 @@ public override Task OnWriteSucceededAsync(TResource resource, WriteOperationKin private void EnsureSnapshot(TResource leftType, IIdentifiable? rightResourceId = null) { - IIdentifiable[] rightResourceIds = rightResourceId != null ? ArrayFactory.Create(rightResourceId) : Array.Empty(); + IIdentifiable[] rightResourceIds = rightResourceId != null ? [rightResourceId] : Array.Empty(); EnsureSnapshot(leftType, rightResourceIds); } From 9546bcb97146434af73eae1a3b3ff91c83c4d92c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 22 Nov 2023 00:48:06 +0100 Subject: [PATCH 49/53] Use char overload instead of passing a single-char string --- src/JsonApiDotNetCore.Annotations/TypeExtensions.cs | 2 +- .../QueryStrings/FilterQueryStringParameterReader.cs | 2 +- .../QueryStrings/SortQueryStringParameterReader.cs | 2 +- .../QueryStrings/SparseFieldSetQueryStringParameterReader.cs | 2 +- .../IntegrationTests/IdObfuscation/HexadecimalCodec.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs index b31f82d48e..785dff030a 100644 --- a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs +++ b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs @@ -48,7 +48,7 @@ public static string GetFriendlyTypeName(this Type type) if (type.IsGenericType) { string typeArguments = type.GetGenericArguments().Select(GetFriendlyTypeName).Aggregate((firstType, secondType) => $"{firstType}, {secondType}"); - return $"{type.Name[..type.Name.IndexOf("`", StringComparison.Ordinal)]}<{typeArguments}>"; + return $"{type.Name[..type.Name.IndexOf('`')]}<{typeArguments}>"; } return type.Name; diff --git a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index 3c8d16a3f0..6ebdd4fd29 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -52,7 +52,7 @@ public virtual bool CanRead(string parameterName) { ArgumentGuard.NotNullNorEmpty(parameterName); - bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith(']'); return parameterName == "filter" || isNested; } diff --git a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 4362f95ed5..d068ae7ce2 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -45,7 +45,7 @@ public virtual bool CanRead(string parameterName) { ArgumentGuard.NotNullNorEmpty(parameterName); - bool isNested = parameterName.StartsWith("sort[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + bool isNested = parameterName.StartsWith("sort[", StringComparison.Ordinal) && parameterName.EndsWith(']'); return parameterName == "sort" || isNested; } diff --git a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index 9757383c39..18d2926931 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -50,7 +50,7 @@ public virtual bool CanRead(string parameterName) { ArgumentGuard.NotNullNorEmpty(parameterName); - return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith(']'); } /// diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index aa12e5ceb3..a3feb15b9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -15,7 +15,7 @@ public int Decode(string? value) return 0; } - if (!value.StartsWith("x", StringComparison.Ordinal)) + if (!value.StartsWith('x')) { throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { From dea63fdba818ed0b6e8dd49c0252363d08d71bba Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 22 Nov 2023 01:12:58 +0100 Subject: [PATCH 50/53] Remove ObjectExtensions.AsEnumerable/AsArray/AsList --- .../Builders/SelectStatementBuilder.cs | 4 ++-- .../UpdateClearOneToOneStatementBuilder.cs | 2 +- .../CollectionConverter.cs | 2 +- .../ObjectExtensions.cs | 15 ------------ .../ServiceCollectionExtensions.cs | 2 +- .../Errors/JsonApiException.cs | 2 +- .../Middleware/ExceptionHandler.cs | 24 ++++++++++++------- .../Middleware/JsonApiMiddleware.cs | 2 +- .../Queries/Parsing/IncludeParser.cs | 2 +- .../Queries/QueryLayerComposer.cs | 8 +++---- .../QueryableBuilding/IncludeClauseBuilder.cs | 2 +- .../QueryableBuilding/SelectClauseBuilder.cs | 2 +- .../SkipTakeClauseBuilder.cs | 2 +- .../QueryableBuilding/WhereClauseBuilder.cs | 8 +++---- .../IncludeQueryStringParameterReader.cs | 2 +- ...parseFieldSetQueryStringParameterReader.cs | 2 +- .../Response/ResponseModelAdapter.cs | 2 +- .../CompositeKeys/CarExpressionRewriter.cs | 3 +-- .../Sum/SumWhereClauseBuilder.cs | 3 +-- .../ReplaceToManyRelationshipTests.cs | 3 +-- .../FieldChainPatternInheritanceMatchTests.cs | 3 +-- .../FieldChainPatternMatchTests.cs | 3 +-- test/TestBuildingBlocks/TestableDbContext.cs | 3 +-- 23 files changed, 43 insertions(+), 58 deletions(-) diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs index cd97a63d32..8bebc3f01f 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -106,8 +106,8 @@ private void TrackPrimaryTable(TableAccessorNode tableAccessor) _selectorsPerTable[tableAccessor] = _selectShape switch { SelectShape.Columns => Array.Empty(), - SelectShape.Count => new CountSelectorNode(null).AsArray(), - _ => new OneSelectorNode(null).AsArray() + SelectShape.Count => [new CountSelectorNode(null)], + _ => [new OneSelectorNode(null)] }; } diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs index 152e14991c..4ccc5fec9a 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs @@ -29,7 +29,7 @@ public UpdateNode Build(ResourceType resourceType, string setColumnName, string ColumnNode whereColumn = table.GetColumn(whereColumnName, null, table.Alias); WhereNode where = GetWhere(whereColumn, whereValue); - return new UpdateNode(table, columnAssignment.AsList(), where); + return new UpdateNode(table, [columnAssignment], where); } private WhereNode GetWhere(ColumnNode column, object? value) diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index 645b5329b7..e95d306329 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -74,7 +74,7 @@ public IReadOnlyCollection ExtractResources(object? value) HashSet resourceSet => resourceSet, IReadOnlyCollection resourceCollection => resourceCollection, IEnumerable resources => resources.ToList(), - IIdentifiable resource => resource.AsArray(), + IIdentifiable resource => [resource], _ => Array.Empty() }; } diff --git a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs index 2e8f9acfc6..8aa1e6c165 100644 --- a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs @@ -4,21 +4,6 @@ namespace JsonApiDotNetCore; internal static class ObjectExtensions { - public static IEnumerable AsEnumerable(this T element) - { - yield return element; - } - - public static T[] AsArray(this T element) - { - return [element]; - } - - public static List AsList(this T element) - { - return [element]; - } - public static HashSet AsHashSet(this T element) { return [element]; diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 7ea42a2470..b25c208086 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -36,7 +36,7 @@ public static IServiceCollection AddJsonApi(this IServiceCollection Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) where TDbContext : DbContext { - return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); + return AddJsonApi(services, options, discovery, resources, mvcBuilder, [typeof(TDbContext)]); } private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, diff --git a/src/JsonApiDotNetCore/Errors/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs index 4571843e8d..097b972089 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -26,7 +26,7 @@ public JsonApiException(ErrorObject error, Exception? innerException = null) { ArgumentGuard.NotNull(error); - Errors = error.AsArray(); + Errors = [error]; } public JsonApiException(IEnumerable errors, Exception? innerException = null) diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 7f7479975a..816c5ffbb7 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -74,15 +74,21 @@ protected virtual IReadOnlyList CreateErrorResponse(Exception excep IReadOnlyList errors = exception switch { JsonApiException jsonApiException => jsonApiException.Errors, - OperationCanceledException => new ErrorObject((HttpStatusCode)499) - { - Title = "Request execution was canceled." - }.AsArray(), - _ => new ErrorObject(HttpStatusCode.InternalServerError) - { - Title = "An unhandled error occurred while processing this request.", - Detail = exception.Message - }.AsArray() + OperationCanceledException => + [ + new ErrorObject((HttpStatusCode)499) + { + Title = "Request execution was canceled." + } + ], + _ => + [ + new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "An unhandled error occurred while processing this request.", + Detail = exception.Message + } + ] }; if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 40a784d25c..7cd2573727 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -205,7 +205,7 @@ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSeri var errorDocument = new Document { - Errors = error.AsList() + Errors = [error] }; await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs index 79f286da91..1ab0e61326 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -86,7 +86,7 @@ private void ParseRelationshipChain(string source, 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(source, treeRoot.AsList()); + ICollection children = ParseRelationshipName(source, [treeRoot]); while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) { diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index d5fac60c71..8e0c97e6c0 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -272,7 +272,7 @@ public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceTyp QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); queryLayer.Sort = null; queryLayer.Pagination = null; - queryLayer.Filter = CreateFilterByIds(id.AsArray(), idAttribute, queryLayer.Filter); + queryLayer.Filter = CreateFilterByIds([id], idAttribute, queryLayer.Filter); if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { @@ -342,7 +342,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, return new QueryLayer(primaryResourceType) { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), - Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), + Filter = CreateFilterByIds([primaryId], primaryIdAttribute, primaryFilter), Selection = primarySelection }; } @@ -390,7 +390,7 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; primaryLayer.Sort = null; primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter); + primaryLayer.Filter = CreateFilterByIds([id], primaryIdAttribute, primaryLayer.Filter); primaryLayer.Selection = null; return primaryLayer; @@ -449,7 +449,7 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); HashSet rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); + FilterExpression? leftFilter = CreateFilterByIds([leftId], leftIdAttribute, null); FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); var secondarySelection = new FieldSelection(); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs index 81a0db533e..3bd19eb21f 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -75,6 +75,6 @@ private static Expression IncludeExtensionMethodCall(Expression source, Type ent { Expression navigationExpression = Expression.Constant(navigationPropertyPath); - return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", entityType.AsArray(), source, navigationExpression); + return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", [entityType], source, navigationExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 43d82d77ce..858532d7c2 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -242,7 +242,7 @@ private static Expression TestForNull(Expression expressionToTest, Expression if private static Expression CopyCollectionExtensionMethodCall(Expression source, string operationName, Type elementType) { - return Expression.Call(typeof(Enumerable), operationName, elementType.AsArray(), source); + return Expression.Call(typeof(Enumerable), operationName, [elementType], source); } private static Expression SelectExtensionMethodCall(Type extensionType, Expression source, Type elementType, Expression selectBody) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs index 2eb2823aca..b48fa696db 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -38,6 +38,6 @@ private static Expression ExtensionMethodCall(Expression source, string operatio { Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); - return Expression.Call(context.ExtensionType, operationName, context.LambdaScope.Parameter.Type.AsArray(), source, constant); + return Expression.Call(context.ExtensionType, operationName, [context.LambdaScope.Parameter.Type], source, constant); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index fa8e69d8a5..f14d795f7b 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -33,7 +33,7 @@ private LambdaExpression GetPredicateLambda(FilterExpression filter, QueryClause private static Expression WhereExtensionMethodCall(LambdaExpression predicate, QueryClauseBuilderContext context) { - return Expression.Call(context.ExtensionType, "Where", context.LambdaScope.Parameter.Type.AsArray(), context.Source, predicate); + return Expression.Call(context.ExtensionType, "Where", [context.LambdaScope.Parameter.Type], context.Source, predicate); } public override Expression VisitHas(HasExpression expression, QueryClauseBuilderContext context) @@ -67,8 +67,8 @@ public override Expression VisitHas(HasExpression expression, QueryClauseBuilder private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) { return predicate != null - ? Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate) - : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); + ? Expression.Call(typeof(Enumerable), "Any", [elementType], source, predicate) + : Expression.Call(typeof(Enumerable), "Any", [elementType], source); } public override Expression VisitIsType(IsTypeExpression expression, QueryClauseBuilderContext context) @@ -125,7 +125,7 @@ public override Expression VisitAny(AnyExpression expression, QueryClauseBuilder private static Expression ContainsExtensionMethodCall(Expression collection, Expression value) { - return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); + return Expression.Call(typeof(Enumerable), "Contains", [value.Type], collection, value); } public override Expression VisitLogical(LogicalExpression expression, QueryClauseBuilderContext context) diff --git a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs index de144de7c5..76bcc4a7b4 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs @@ -72,6 +72,6 @@ public virtual IReadOnlyCollection GetConstraints() ? new ExpressionInScope(null, _includeExpression) : new ExpressionInScope(null, IncludeExpression.Empty); - return expressionInScope.AsArray(); + return [expressionInScope]; } } diff --git a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index 18d2926931..559da09f38 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -100,7 +100,7 @@ private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, Resour public virtual IReadOnlyCollection GetConstraints() { return _sparseFieldTableBuilder.Any() - ? new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable())).AsArray() + ? [new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable()))] : Array.Empty(); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 4bd6844335..9457b05502 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -107,7 +107,7 @@ public Document Convert(object? model) } else if (model is ErrorObject errorObject) { - document.Errors = errorObject.AsArray(); + document.Errors = [errorObject]; } else { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index 6d887a3e9e..c85a2b2a31 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Reflection; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -42,7 +41,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) } string carStringId = (string)rightConstant.TypedValue; - return RewriteFilterOnCarStringIds(leftChain, carStringId.AsEnumerable()); + return RewriteFilterOnCarStringIds(leftChain, [carStringId]); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs index a732490123..b14150e793 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs @@ -1,5 +1,4 @@ using System.Linq.Expressions; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.QueryableBuilding; @@ -42,6 +41,6 @@ private LambdaExpression GetSelectorLambda(QueryExpression expression, QueryClau private static Expression SumExtensionMethodCall(LambdaExpression selector, QueryClauseBuilderContext context) { - return Expression.Call(context.ExtensionType, "Sum", context.LambdaScope.Parameter.Type.AsArray(), context.Source, selector); + return Expression.Call(context.ExtensionType, "Sum", [context.LambdaScope.Parameter.Type], context.Source, selector); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index 9b9f96ddce..32560b2ad9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -1,6 +1,5 @@ using System.Net; using FluentAssertions; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; @@ -916,7 +915,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => dbContext.WorkItems.Add(existingWorkItem); await dbContext.SaveChangesAsync(); - existingWorkItem.Children = existingWorkItem.AsList(); + existingWorkItem.Children = [existingWorkItem]; await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index fece0100f8..f308b40d2d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs @@ -1,6 +1,5 @@ using FluentAssertions; using JetBrains.Annotations; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources; @@ -47,7 +46,7 @@ public sealed class FieldChainPatternInheritanceMatchTests public FieldChainPatternInheritanceMatchTests(ITestOutputHelper testOutputHelper) { var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); - _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); + _loggerFactory = new LoggerFactory([loggerProvider]); var options = new JsonApiOptions(); _resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Add().Add().Build(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index cfffac4e2c..dec0e7c33d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -1,6 +1,5 @@ using FluentAssertions; using JetBrains.Annotations; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources; @@ -33,7 +32,7 @@ public sealed class FieldChainPatternMatchTests public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) { var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); - _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); + _loggerFactory = new LoggerFactory([loggerProvider]); var options = new JsonApiOptions(); IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); diff --git a/test/TestBuildingBlocks/TestableDbContext.cs b/test/TestBuildingBlocks/TestableDbContext.cs index b92f6be261..65cb8872be 100644 --- a/test/TestBuildingBlocks/TestableDbContext.cs +++ b/test/TestBuildingBlocks/TestableDbContext.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using JsonApiDotNetCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; @@ -21,7 +20,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) [Conditional("DEBUG")] private static void WriteSqlStatementsToOutputWindow(DbContextOptionsBuilder builder) { - builder.LogTo(message => Debug.WriteLine(message), DbLoggerCategory.Database.Name.AsArray(), LogLevel.Information); + builder.LogTo(message => Debug.WriteLine(message), [DbLoggerCategory.Database.Name], LogLevel.Information); } protected override void OnModelCreating(ModelBuilder builder) From 6f15f5a3f0087a2c83ba66d83b172b2a16e64714 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 22 Nov 2023 01:24:37 +0100 Subject: [PATCH 51/53] Fix broken build --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca7042c976..4d7442a3c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -265,7 +265,7 @@ jobs: shell: pwsh run: | Write-Output "Running code cleanup on all files." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version)--jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff publish: timeout-minutes: 60 From 4d2d549254ff0d5820160987e7458e896224f9e3 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 22 Nov 2023 01:25:49 +0100 Subject: [PATCH 52/53] Update version to 5.5.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7ce9eb9a00..cdd3c5d232 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,6 +27,6 @@ false $(MSBuildThisFileDirectory)CodingGuidelines.ruleset $(MSBuildThisFileDirectory)tests.runsettings - 5.4.1 + 5.5.0 From 8137c4e9038a10ae86390df5552ac39e2861beb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Nov 2023 01:31:48 +0000 Subject: [PATCH 53/53] Bump dotnet-reportgenerator-globaltool from 5.1.26 to 5.2.0 (#1392) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index c8c97435a8..7f98da7f1a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,7 +15,7 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.26", + "version": "5.2.0", "commands": [ "reportgenerator" ] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy