diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3919f2223e..7f98da7f1a 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.3.0-eap08", "commands": [ "jb" ] @@ -15,13 +15,13 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.25", + "version": "5.2.0", "commands": [ "reportgenerator" ] }, "docfx": { - "version": "2.70.4", + "version": "2.74.0", "commands": [ "docfx" ] 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: '' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a796be61f0..4d7442a3c9 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: | @@ -99,10 +101,10 @@ 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 + $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: @@ -124,27 +126,20 @@ 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 + 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: | - 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 @@ -188,7 +183,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 @@ -199,7 +196,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: | @@ -239,7 +236,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: @@ -260,13 +259,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/.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}}" 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 diff --git a/Build.ps1 b/Build.ps1 index 4f0912079d..3abc926e6a 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,18 +8,22 @@ function VerifySuccessExitCode { 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 --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 +dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" 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 diff --git a/Directory.Build.props b/Directory.Build.props index 1c96340f4f..cdd3c5d232 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,44 +1,32 @@ - - net6.0 - 6.0.* - 7.0.* - 7.0.* - 4.7.* - 2.14.1 - 5.4.0 - $(MSBuildThisFileDirectory)CodingGuidelines.ruleset - 9999 - enable - enable - false - false - - - - - - - - - - true + + $(NoWarn);AV2210 - + $(NoWarn);1591 true true - - $(NoWarn);AV2210 + + true - + + + + + + - 6.0.* - 2.3.* - 17.7.* + enable + latest + enable + false + false + $(MSBuildThisFileDirectory)CodingGuidelines.ruleset + $(MSBuildThisFileDirectory)tests.runsettings + 5.5.0 diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 2f8e9f9127..e821d4175d 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -13,6 +13,8 @@ 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 + package-versions.props = package-versions.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{026FBC6C-AF76-4568-9B87-EC73457899FD}" @@ -55,6 +57,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 @@ -281,6 +287,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 @@ -304,6 +334,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..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,7 +93,9 @@ 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> JADNC Full Cleanup Required @@ -113,6 +116,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); True True True + INDENT 1 1 False @@ -125,6 +129,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); False False True + 1 NEVER NEVER False @@ -145,6 +150,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); True WRAP_IF_LONG 160 + CHOP_IF_LONG WRAP_IF_LONG CHOP_ALWAYS CHOP_ALWAYS @@ -659,8 +665,12 @@ $left$ = $right$; True True True + True True + True True + True + True True True True diff --git a/README.md b/README.md index 90bb47b405..f34dfd1bee 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 @@ -77,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/WarningSeverities.DotSettings b/WarningSeverities.DotSettings index 96f358da23..5c641e606f 100644 --- a/WarningSeverities.DotSettings +++ b/WarningSeverities.DotSettings @@ -124,6 +124,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -163,6 +164,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -240,6 +242,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -258,10 +261,12 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING WARNING WARNING + WARNING \ No newline at end of file diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 23a6876af9..9dbb9ba093 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -1,17 +1,19 @@ Exe - $(TargetFrameworkName) + net8.0 true + + - - + + 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/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/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/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/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/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 +} 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..f49c3e4b40 --- /dev/null +++ b/src/Examples/DapperExample/DapperExample.csproj @@ -0,0 +1,21 @@ + + + net8.0;net6.0 + + + + + + + + + + + + + + + + + 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..67c19bea4a --- /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 = []; + + 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..dc7c1802c8 --- /dev/null +++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs @@ -0,0 +1,51 @@ +using System.ComponentModel; +using DapperExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.Definitions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemDefinition : JsonApiResourceDefinition +{ + private readonly IClock _clock; + + public TodoItemDefinition(IResourceGraph resourceGraph, IClock clock) + : base(resourceGraph) + { + ArgumentGuard.NotNull(clock); + + _clock = clock; + } + + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } + + private SortExpression GetDefaultSortOrder() + { + return CreateSortExpressionFromLambda([ + (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 = _clock.UtcNow; + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + resource.LastModifiedAt = _clock.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/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/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..00ab54ca97 --- /dev/null +++ b/src/Examples/DapperExample/Program.cs @@ -0,0 +1,114 @@ +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.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..4d30e430c7 --- /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 = []; + + 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 = []; + + 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..c263ad7767 --- /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 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 ExecuteQueryAsync(connection => 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..1d9b998340 --- /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 = []; + private Dictionary _newColumnValues = []; + + private Dictionary> _currentRightResourcesByRelationship = []; + private Dictionary> _newRightResourcesByRelationship = []; + + 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 = []; + + foreach ((string columnName, ResourceFieldAttribute? _) in _dataModelService.GetColumnMappings(ResourceType)) + { + columnValues[columnName] = _dataModelService.GetColumnValue(ResourceType, resource, columnName); + } + + return columnValues; + } + + private Dictionary> CaptureRightResourcesByRelationship(IIdentifiable resource) + { + Dictionary> relationshipValues = []; + + 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 = []; + + 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 = []; + + 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 = []; + + 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 = []; + + 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() + : []; + + 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.GetClrType(); + + 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..61421d7331 --- /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 = []; + + // 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 = []; + + // Optimization to avoid unneeded calls to expensive Activator.CreateInstance() method, which is needed multiple times per row. + 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 = []; + + // 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)] = []; + + 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] = []; + _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..15d4e95d81 --- /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 = []; + + 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/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/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..7e444b45a1 --- /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 = []; + 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..8bebc3f01f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -0,0 +1,782 @@ +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; + } + + 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, []); + + _selectorsPerTable[tableAccessor] = _selectShape switch + { + SelectShape.Columns => Array.Empty(), + SelectShape.Count => [new CountSelectorNode(null)], + _ => [new OneSelectorNode(null)] + }; + } + + 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 = []; + Dictionary nextLayers = []; + + 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 = []; + + 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 = []; + + foreach (ColumnNode column in columns.OrderBy(column => column.GetTableAliasIndex()).ThenBy(column => column.Name)) + { + string tableAlias = column.TableAlias ?? "!"; + selectorsPerTable.TryAdd(tableAlias, []); + + 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, []); + _selectorsPerTable[rightTableAccessor] = Array.Empty(); + + _queryState.RelatedTables[leftTableAccessor].Add(relationship, rightTableAccessor); + } + + private IReadOnlyList MapSelectorsFromSubQuery(IEnumerable innerSelectorsToKeep, SelectNode select) + { + List outerColumnsToKeep = []; + + 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 = []; + + 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.GetValueOrDefault(relationship); + } + + 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 = []; + + 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; } = []; + + // 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; } = []; + + 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..3e3d48eb10 --- /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 = []; + 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..4ccc5fec9a --- /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], 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..62fc3b7e20 --- /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 = []; + + 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..589852ad80 --- /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 = []; + + 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 = []; + + 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.GetClrType().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.GetClrType(); + + 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..0f030debdb --- /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 = []; + private readonly Dictionary _columnNullabilityPerAttribute = []; + 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..5dc1591bca --- /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 = + [ + typeof(bool), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(short), + typeof(ushort), + typeof(sbyte), + typeof(float), + typeof(double), + typeof(decimal) + ]; + + 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..b5d560448c --- /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 = []; + 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..03692baf2d --- /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 []; + } + + Dictionary topElement = _tablesInScopeStack.Peek(); + return new Dictionary(topElement); + } + + private IReadOnlyDictionary> VisitSelectors( + IReadOnlyDictionary> selectors, ColumnVisitMode mode) + { + Dictionary> newSelectors = []; + + 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..7cffc8e29a --- /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 = []; + + 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 = []; + + 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..92a24bac6d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs @@ -0,0 +1,6 @@ +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..0fc42b1ba0 --- /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 = []; + + 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..da1b097757 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs @@ -0,0 +1,6 @@ +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..31977f1546 --- /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 = []; + + 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/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/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/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index a48f472a70..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/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/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index ab152b79d5..1f4645f323 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + 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 c533143855..31aee37585 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -5,20 +5,30 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Models; +#if NET6_0 using Microsoft.AspNetCore.Authentication; +#endif namespace JsonApiDotNetCoreExample.Definitions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemDefinition : JsonApiResourceDefinition { - 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) { @@ -27,22 +37,21 @@ 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) { 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 a48f472a70..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 2884e7750c..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] @@ -27,29 +29,33 @@ 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) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure services"); +#if NET6_0 builder.Services.TryAddSingleton(); +#else + builder.Services.TryAddSingleton(TimeProvider.System); +#endif builder.Services.AddDbContext(options => { - string? connectionString = GetConnectionString(builder.Configuration); + string? connectionString = builder.Configuration.GetConnectionString("Default"); options.UseNpgsql(connectionString); SetDbContextDebugOptions(options); @@ -73,12 +79,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) { @@ -87,18 +87,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/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/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index ab152b79d5..1f4645f323 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + 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/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/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index 9f0037b058..c5b18320f0 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + 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/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/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index a48f472a70..bff4909317 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -1,16 +1,13 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - - - - - diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index 6edce84335..e95d306329 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], + _ => Array.Empty() + }; } /// 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/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index 119d295b35..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 @@ -46,7 +47,7 @@ - - + + diff --git a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs index b7fd934fbe..8aa1e6c165 100644 --- a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs @@ -4,32 +4,8 @@ namespace JsonApiDotNetCore; internal static class ObjectExtensions { - public static IEnumerable AsEnumerable(this T element) - { - yield return element; - } - - public static T[] AsArray(this T element) - { - return new[] - { - element - }; - } - - public static List AsList(this T element) - { - return new List - { - element - }; - } - public static HashSet AsHashSet(this T element) { - return new HashSet - { - element - }; + return [element]; } } 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/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/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.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.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.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/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index 6d79d8c893..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 @@ -45,7 +46,7 @@ - - + + 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/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/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/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/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 edf67a3f8e..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) { @@ -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) 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/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/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 85f95c232f..01f9ecc7cb 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; @@ -15,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<,>), @@ -30,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; @@ -119,7 +117,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); } } @@ -154,16 +152,18 @@ 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); if (result != null) { (Type implementationType, Type serviceInterface) = result.Value; - _services.AddScoped(serviceInterface, implementationType); + _services.TryAddScoped(serviceInterface, implementationType); } } } 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/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/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..ce8ab8a1b0 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)) @@ -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/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/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 6a1b8517e6..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 @@ -36,11 +38,11 @@ - - - - - - + + + + + + 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/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/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/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/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/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/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index e00bbd50a8..0b20b897d1 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,105 @@ 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 AbstractResourceWrapperInTraceJsonConverterFactory(), + 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) + { + Type converterType = typeof(ResourceFieldInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + where TField : ResourceFieldAttribute + { + public override TField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, TField 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) + { + // 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 @@ -88,26 +188,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 +204,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/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/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/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/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/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..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) { @@ -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/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/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index d7f80b8aa2..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) { @@ -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); @@ -337,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 }; } @@ -385,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; @@ -444,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 3b0793f774..3bd19eb21f 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); @@ -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/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/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/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..858532d7c2 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); } @@ -241,12 +242,17 @@ 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) { - Type[] typeArguments = ArrayFactory.Create(elementType, elementType); + Type[] typeArguments = + [ + elementType, + elementType + ]; + return Expression.Call(extensionType, "Select", typeArguments, source, 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 981b2da1a3..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) @@ -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) @@ -130,24 +125,19 @@ 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) { 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) 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..6ebdd4fd29 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; @@ -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/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/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/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..d068ae7ce2 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; @@ -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..559da09f38 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(']'); } /// @@ -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/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/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/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/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index ccf33fcd2f..457f5082ef 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -24,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, null)!; + return (JsonConverter)Activator.CreateInstance(converterType)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> 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/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/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/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/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/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/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/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 new file mode 100644 index 0000000000..45d9c6a88d --- /dev/null +++ b/test/DapperTests/DapperTests.csproj @@ -0,0 +1,19 @@ + + + net8.0;net6.0 + + + + + + + + + + + + + + + + diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs new file mode 100644 index 0000000000..286820b702 --- /dev/null +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -0,0 +1,553 @@ +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(dbContext => _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..6bbcca6777 --- /dev/null +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -0,0 +1,167 @@ +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.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; +using IClock = DapperExample.IClock; + +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 FrozenClock + { + 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}'.") + }; + +#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. + } + } + } + + 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/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/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs new file mode 100644 index 0000000000..56bfdd5a7c --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs @@ -0,0 +1,1303 @@ +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..09d307a256 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs @@ -0,0 +1,240 @@ +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..33643519ce --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs @@ -0,0 +1,428 @@ +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..e66616810e --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.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 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..e6c49c1f62 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,95 @@ +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..227bbdb755 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs @@ -0,0 +1,199 @@ +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..f31a89c73e --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,232 @@ +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..05b0710132 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,421 @@ +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..e42efaf2e1 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,1190 @@ +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..199c8641e6 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs @@ -0,0 +1,762 @@ +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..af81d138a6 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs @@ -0,0 +1,129 @@ +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..0027de1d38 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs @@ -0,0 +1,350 @@ +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..f51b7c6b52 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs @@ -0,0 +1,415 @@ +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..ed75d24d65 --- /dev/null +++ b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs @@ -0,0 +1,635 @@ +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..a88646c7a7 --- /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..fe58b3b183 --- /dev/null +++ b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs @@ -0,0 +1,58 @@ +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..53ef375e0a --- /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/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/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/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/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 2af39b3d26..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.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] 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/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 3331b1f1cc..c77610d8dc 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; @@ -27,10 +26,7 @@ public AtomicCreateResourceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(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/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 15dbc19b07..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; @@ -24,7 +23,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); 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/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index a6e162f72a..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.ConfigureServicesAfterStartup(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 db6ee06bbf..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.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 2bd9dc8edf..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; @@ -22,7 +21,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..bae5abf988 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(); }); } @@ -84,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)); } @@ -121,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/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 0dcc99fdcd..4f6a2f0a3c 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(); @@ -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 new file mode 100644 index 0000000000..14e1e4fd61 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -0,0 +1,333 @@ +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/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 03a04fb431..1d095377bd 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; @@ -18,10 +17,7 @@ public AtomicModelStateValidationTests(IntegrationTestContext - { - services.AddSingleton(); - }); + _testContext.ConfigureServices(services => services.AddSingleton()); testContext.UseController(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index 392a76c08d..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; @@ -21,10 +20,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/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/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index 27e44ec234..b8095b38f9 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(); @@ -42,10 +42,7 @@ public async Task Transforms_on_create_resource_with_side_effects() List newCompanies = _fakers.RecordCompany.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { 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/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 82646686d4..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(async dbContext => - { - await 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(async dbContext => - { - await dbContext.ClearTablesAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTablesAsync()); const string trackLid = "track-1"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index d5e74fa4c3..9c054c349b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -21,16 +21,14 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceRepository(); services.AddResourceRepository(); 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/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/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 895d1a0df5..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; @@ -25,7 +24,7 @@ public AtomicUpdateResourceTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); 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/Blobs/BlobTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs index 4d21e284e8..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.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] 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/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/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 2b4a8c70d6..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); }); - ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(_loggerFactory); - }); + ConfigureServices(services => services.AddSingleton(_loggerFactory)); } [Fact] @@ -38,10 +35,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/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/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..06ad682591 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(); }); } @@ -83,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] @@ -95,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"; @@ -126,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] @@ -169,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/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/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 1e95efa717..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) { @@ -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/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 5a0738b0a9..21fe575a2b 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,11 @@ public ModelStateValidationTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => - { +#if NET6_0 + testContext.ConfigureServices(services => // Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation. - services.AddDateOnlyTimeOnlyStringConverters(); - }); + services.AddDateOnlyTimeOnlyStringConverters()); +#endif } [Fact] @@ -125,7 +127,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 +536,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/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index 4653118ab6..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.ConfigureServicesAfterStartup(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 62ba148dc4..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.ConfigureServicesAfterStartup(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 0d496f18eb..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.ConfigureServicesAfterStartup(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 a51f522b82..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.ConfigureServicesAfterStartup(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 cf614fc8f8..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.ConfigureServicesAfterStartup(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/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 d297467bc4..cc56c3980f 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,12 +27,10 @@ public LoggingTests(IntegrationTestContext, Lo options.ClearProviders(); options.AddProvider(loggerFactory); options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, _) => category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); }); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); - }); + testContext.ConfigureServices(services => services.AddSingleton(loggerFactory)); } [Fact] @@ -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,233 @@ 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(); + + 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(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body.")); + 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; } +} 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..472803c67d 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.ConfigureServicesAfterStartup(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(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); const string route = "/supportTickets"; @@ -45,22 +39,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // 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/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index 8bfb268daa..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.ConfigureServicesAfterStartup(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(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); const string route = "/supportTickets"; 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/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index 9365ff08a0..b30c8ef88f 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; @@ -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 499c572b88..74b5190cd1 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; @@ -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..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(async dbContext => - { - await 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 5ec47bb34a..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(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { 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/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/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/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/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/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/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/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/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 3a171bdae0..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; @@ -25,7 +24,7 @@ public TimeOffsetTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => + testContext.ConfigureServices(services => { services.AddTransient(); services.AddSingleton(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index ad6f8a1609..ac1991ce4d 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] @@ -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/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/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 44148b19e7..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(); }); } @@ -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); 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/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 58c804a073..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.ConfigureServicesAfterStartup(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/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/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 82c21e1369..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.ConfigureServicesAfterStartup(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/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 5836c2d7de..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; @@ -22,10 +21,7 @@ public ReplaceToManyRelationshipTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] @@ -919,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(); }); @@ -968,7 +964,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/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..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.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] 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 19b38cf071..3f371b2a67 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; @@ -22,10 +21,7 @@ public ResourceInjectionTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServices(services => services.AddSingleton()); _fakers = new InjectionFakers(testContext.Factory.Services); } 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/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/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..6f830f873f 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.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.UseRelativeLinks = true; @@ -1406,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/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/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); } 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/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/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/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/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 da87b6b82d..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(); @@ -23,10 +30,7 @@ public SerializationTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(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; @@ -98,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] @@ -188,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] @@ -242,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] @@ -302,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] @@ -349,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] @@ -385,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] @@ -436,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] @@ -465,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] @@ -523,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] @@ -556,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] @@ -589,29 +613,31 @@ 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"": { - ""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] @@ -649,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] @@ -713,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] @@ -813,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] @@ -843,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/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 299a40bad8..6a865eaf85 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; @@ -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/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 63ae9689a0..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; @@ -25,15 +24,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/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 93881be9eb..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(async dbContext => - { - await 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 19baa0cf58..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(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(dbContext => dbContext.ClearTableAsync()); var requestBody = new { diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index e66fbaeacc..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/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs index 8642461d50..9e96bc718a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs @@ -1,15 +1,23 @@ 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; +// 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 @@ -69,6 +77,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 => @@ -99,9 +156,7 @@ public SomeSingletonService(SomeScopedService scopedService) } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class SomeScopedService - { - } + private sealed class SomeScopedService; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] private sealed class CircularServiceA @@ -140,4 +195,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/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs similarity index 93% rename from test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs rename to test/JsonApiDotNetCoreTests/UnitTests/Configuration/ServiceCollectionExtensionsTests.cs index 0cb24d8025..0f7e8b0b09 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,33 +164,10 @@ 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 - { - } + 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 @@ -594,7 +571,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) @@ -603,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/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index fcae662098..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; @@ -11,6 +10,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 @@ -43,8 +45,8 @@ public sealed class FieldChainPatternInheritanceMatchTests public FieldChainPatternInheritanceMatchTests(ITestOutputHelper testOutputHelper) { - var loggerProvider = new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message); - _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); + var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); + _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 964cf6fb8c..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; @@ -11,6 +10,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 @@ -29,8 +31,8 @@ public sealed class FieldChainPatternMatchTests public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) { - var loggerProvider = new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message); - _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); + var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); + _loggerFactory = new LoggerFactory([loggerProvider]); var options = new JsonApiOptions(); IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); 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); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 52dc5cb19c..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 { @@ -403,7 +401,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 +425,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 } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs index a57f5ff5b0..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; @@ -172,9 +175,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/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/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/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 3d178073c7..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 @@ -24,24 +27,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 +57,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 +88,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 +118,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 +140,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 +162,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 +184,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 +206,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 +228,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 +249,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 +270,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 +292,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 +313,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/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/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 97a35603b3..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 @@ -263,11 +266,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 +285,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 +301,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 +319,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] @@ -429,15 +431,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/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/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/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/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index 0f5f5f2cff..54497bfada 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + @@ -11,7 +13,7 @@ - + diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index f651f73c0e..080666d491 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + @@ -11,7 +13,7 @@ - + diff --git a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs index 57da032819..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 @@ -498,7 +501,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/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/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/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() 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/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index 707de9b8c5..e28bdc20d1 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + @@ -12,7 +14,7 @@ - + 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/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/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs index 7f32073874..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) @@ -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/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..9d8e74c5b3 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 = []; 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/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/IgnoreLineEndingsComparer.cs b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs new file mode 100644 index 0000000000..bb043bd0c5 --- /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(); + } +} 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/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index f5d2b072f6..dc186b474b 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 { @@ -65,18 +64,15 @@ 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(); factory.ConfigureLogging(_loggingConfiguration); - factory.ConfigureServicesBeforeStartup(services => + factory.ConfigureServices(services => { - _beforeServicesConfiguration?.Invoke(services); + _configureServices?.Invoke(services); services.ReplaceControllers(_testControllerProvider); @@ -87,8 +83,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 +108,9 @@ 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; } public async Task RunOnDatabaseAsync(Func asyncAction) @@ -151,22 +140,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() @@ -187,22 +170,10 @@ protected override IHostBuilder CreateHostBuilder() }) .ConfigureWebHostDefaults(webBuilder => { - webBuilder.ConfigureServices(services => - { - _beforeServicesConfiguration?.Invoke(services); - }); - + webBuilder.ConfigureServices(services => _configureServices?.Invoke(services)); webBuilder.UseStartup(); - - webBuilder.ConfigureServices(services => - { - _afterServicesConfiguration?.Invoke(services); - }); }) - .ConfigureLogging(options => - { - _loggingConfiguration?.Invoke(options); - }); + .ConfigureLogging(options => _loggingConfiguration?.Invoke(options)); // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore 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); } } diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 386e4e846e..ae6cf32ff6 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -1,22 +1,24 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + - + - - - + + + - - - + + + 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) 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() - { - } } } } 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/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 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 diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 85bcc57484..99fc7ce781 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net8.0;net6.0 + + @@ -10,7 +12,7 @@ - + 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 + + + + +